Created
May 21, 2026 20:48
-
-
Save HerrNiklasRaab/42215f4a5201256b9158a92995a7c8ae to your computer and use it in GitHub Desktop.
InstantDB optional-field response shape — owner can't distinguish 'never written' from 'redacted' when the field has a perm rule
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Reproduces InstantDB's response shape for optional fields under different | |
| // permission/value combinations. Run with: | |
| // | |
| // npm i @instantdb/admin | |
| // INSTANTDB_APP_ID=... INSTANTDB_ADMIN_TOKEN=... npx tsx instantdb-optional-field-shape.ts | |
| // | |
| // The app must have the schema + perms below pushed first: | |
| // | |
| // npx instant-cli@latest push schema -p admin -a $INSTANTDB_APP_ID -y | |
| // npx instant-cli@latest push perms -p admin -a $INSTANTDB_APP_ID -y | |
| // | |
| // Expected: case #2 fails. The owner is allowed to read `secretField`, but | |
| // because it has a field-level perm rule AND was never written, InstantDB | |
| // omits the key entirely — indistinguishable from "you can't see it." | |
| import { i, init, id } from "@instantdb/admin"; | |
| const schema = i.schema({ | |
| entities: { | |
| users: i.entity({ | |
| name: i.string(), | |
| secretField: i.string().optional(), | |
| testDate: i.date().indexed().optional(), | |
| status: i.string<"active" | "inactive" | "pending">().indexed().optional(), | |
| role: i.string<"admin" | "member" | "guest">().indexed().optional(), | |
| createdAt: i.date().indexed(), | |
| updatedAt: i.date().indexed(), | |
| deletedAt: i.date().indexed().optional(), | |
| }), | |
| }, | |
| }); | |
| // Push these via `instant-cli push perms` to the same app. | |
| export const perms = { | |
| users: { | |
| bind: ["isOwner", "auth.id != null && auth.id == data.id"], | |
| allow: { | |
| view: "true", | |
| create: "true", | |
| update: "isOwner", | |
| delete: "true", | |
| }, | |
| fields: { | |
| secretField: "isOwner", | |
| }, | |
| }, | |
| }; | |
| const appId = process.env.INSTANTDB_APP_ID; | |
| const adminToken = process.env.INSTANTDB_ADMIN_TOKEN; | |
| if (!appId || !adminToken) { | |
| throw new Error("Set INSTANTDB_APP_ID and INSTANTDB_ADMIN_TOKEN"); | |
| } | |
| const adminDb = init({ appId, adminToken, schema }); | |
| type UsersSchema = typeof schema; | |
| function userTx(userId: string) { | |
| const chunk = adminDb.tx.users[userId]; | |
| if (!chunk) throw new Error(`no tx chunk for users[${userId}]`); | |
| return chunk; | |
| } | |
| async function seedAuthUser(email: string): Promise<string> { | |
| await adminDb.auth.createToken({ email }); | |
| const user = await adminDb.auth.getUser({ email }); | |
| if (!user) throw new Error(`no auth user for ${email}`); | |
| return user.id; | |
| } | |
| type Row = Record<string, unknown>; | |
| function assert(name: string, cond: boolean, detail?: unknown): void { | |
| if (cond) { | |
| console.log(` ✓ ${name}`); | |
| } else { | |
| console.log(` ✗ ${name}`, detail ?? ""); | |
| process.exitCode = 1; | |
| } | |
| } | |
| async function case1_noPermNeverWritten(): Promise<void> { | |
| console.log("\n#1 no field-perm rule, never written"); | |
| const userId = id(); | |
| const now = new Date().toISOString(); | |
| await adminDb.transact([ | |
| userTx(userId).update({ name: "Alice", createdAt: now, updatedAt: now }), | |
| ]); | |
| const resp = await adminDb.query({ | |
| users: { $: { where: { id: userId } } }, | |
| }); | |
| const row = resp.users[0] as Row | undefined; | |
| assert("row exists", !!row); | |
| if (!row) return; | |
| assert("'testDate' in row", "testDate" in row); | |
| assert("row.testDate === null", row.testDate === null, row.testDate); | |
| } | |
| async function case2_permRuleOwnerNeverWritten(): Promise<void> { | |
| console.log("\n#2 field-perm rule, owner, never written (THIS IS THE BUG)"); | |
| const email = `owner-${id()}@example.com`; | |
| const ownerId = await seedAuthUser(email); | |
| const now = new Date().toISOString(); | |
| await adminDb.transact([ | |
| userTx(ownerId).update({ name: "Owner", createdAt: now, updatedAt: now }), | |
| ]); | |
| const resp = await adminDb.asUser({ email }).query({ | |
| users: { $: { where: { id: ownerId } } }, | |
| }); | |
| const row = resp.users[0] as Row | undefined; | |
| assert("row exists", !!row); | |
| if (!row) return; | |
| assert("'secretField' in row (expect TRUE; will FAIL — key is absent)", "secretField" in row); | |
| assert("row.secretField === null", row.secretField === null, row.secretField); | |
| } | |
| async function case3_permRuleNotAllowedSetValue(): Promise<void> { | |
| console.log("\n#3 field-perm rule, non-owner, value written"); | |
| const ownerEmail = `owner-${id()}@example.com`; | |
| const viewerEmail = `viewer-${id()}@example.com`; | |
| const ownerId = await seedAuthUser(ownerEmail); | |
| await seedAuthUser(viewerEmail); | |
| const now = new Date().toISOString(); | |
| await adminDb.transact([ | |
| userTx(ownerId).update({ | |
| name: "Owner", | |
| secretField: "top-secret", | |
| createdAt: now, | |
| updatedAt: now, | |
| }), | |
| ]); | |
| const resp = await adminDb.asUser({ email: viewerEmail }).query({ | |
| users: { $: { where: { id: ownerId } } }, | |
| }); | |
| const row = resp.users[0] as Row | undefined; | |
| assert("row exists", !!row); | |
| if (!row) return; | |
| assert("'secretField' in row === false", !("secretField" in row)); | |
| } | |
| async function case4_permRuleOwnerExplicitNull(): Promise<void> { | |
| console.log("\n#4 field-perm rule, owner, explicitly written null"); | |
| const email = `owner-${id()}@example.com`; | |
| const ownerId = await seedAuthUser(email); | |
| const now = new Date().toISOString(); | |
| await adminDb.transact([ | |
| userTx(ownerId).update({ | |
| name: "Owner", | |
| secretField: null, | |
| createdAt: now, | |
| updatedAt: now, | |
| }), | |
| ]); | |
| const resp = await adminDb.asUser({ email }).query({ | |
| users: { $: { where: { id: ownerId } } }, | |
| }); | |
| const row = resp.users[0] as Row | undefined; | |
| assert("row exists", !!row); | |
| if (!row) return; | |
| assert("'secretField' in row", "secretField" in row); | |
| assert("row.secretField === null", row.secretField === null, row.secretField); | |
| } | |
| async function case5_permRuleOwnerSetValue(): Promise<void> { | |
| console.log("\n#5 field-perm rule, owner, value written (control)"); | |
| const email = `owner-${id()}@example.com`; | |
| const ownerId = await seedAuthUser(email); | |
| const now = new Date().toISOString(); | |
| await adminDb.transact([ | |
| userTx(ownerId).update({ | |
| name: "Owner", | |
| secretField: "top-secret", | |
| createdAt: now, | |
| updatedAt: now, | |
| }), | |
| ]); | |
| const resp = await adminDb.asUser({ email }).query({ | |
| users: { $: { where: { id: ownerId } } }, | |
| }); | |
| const row = resp.users[0] as Row | undefined; | |
| assert("row exists", !!row); | |
| if (!row) return; | |
| assert("'secretField' in row", "secretField" in row); | |
| assert("row.secretField === 'top-secret'", row.secretField === "top-secret", row.secretField); | |
| } | |
| await case1_noPermNeverWritten(); | |
| await case2_permRuleOwnerNeverWritten(); | |
| await case3_permRuleNotAllowedSetValue(); | |
| await case4_permRuleOwnerExplicitNull(); | |
| await case5_permRuleOwnerSetValue(); | |
| void schema as UsersSchema; | |
| void perms; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment