|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21516] One name space for the engine's in-process verbs and the data door, |
| 5 | + * pinned at the door a deployment exposes: an action body run through REST |
| 6 | + * `/actions`. |
| 7 | + * |
| 8 | + * A sandboxed body's object API reaches the engine's in-process verbs. Those |
| 9 | + * verbs used to hand a name the schema registry does not resolve to the driver |
| 10 | + * as a raw table name, so a body could read a table by a name the generic data |
| 11 | + * door refuses with `404 OBJECT_NOT_FOUND` — and every in-process guard keyed by |
| 12 | + * a registered object name could be stepped around by naming the target some |
| 13 | + * other way. The engine now refuses that name with the door's own envelope. |
| 14 | + * |
| 15 | + * The target is a table that EXISTS and holds a row, created out of band at the |
| 16 | + * driver and registered nowhere: the class the card measured, without naming |
| 17 | + * any protected table. Pinned, per the triage ruling: |
| 18 | + * - the measured exit now answers not-found, for a member and an |
| 19 | + * administrator (the action runs elevated, so both reach the same engine); |
| 20 | + * - a registered name read the same way is the control; |
| 21 | + * - the data door's own answer for the same name is the reference. |
| 22 | + * |
| 23 | + * Composition: the plugin set, in order, `@objectstack/verify`'s `bootStack` |
| 24 | + * uses (it mirrors `objectstack dev` / `serve`), as the #21454 reader-seam pins |
| 25 | + * assemble it; the boot is paid in `beforeAll`, never inside a case. |
| 26 | + */ |
| 27 | + |
| 28 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 29 | +import { ObjectKernel } from '@objectstack/core'; |
| 30 | +import { ObjectQLPlugin } from '@objectstack/objectql'; |
| 31 | +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; |
| 32 | +import { createRestApiPlugin } from '@objectstack/rest'; |
| 33 | +import { AuthPlugin } from '@objectstack/plugin-auth'; |
| 34 | +import { SecurityPlugin, appSecurityPluginOptions } from '@objectstack/plugin-security'; |
| 35 | +import { SharingServicePlugin } from '@objectstack/plugin-sharing'; |
| 36 | +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; |
| 37 | +import { AppPlugin } from './app-plugin.js'; |
| 38 | +import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; |
| 39 | +import { createDispatcherPlugin } from './dispatcher-plugin.js'; |
| 40 | + |
| 41 | +const BOOT_TIMEOUT = 180_000; |
| 42 | +const ORIGIN = 'http://localhost:3000'; |
| 43 | +const API = '/api/v1'; |
| 44 | +const ADMIN = { email: 'admin@objectos.ai', password: 'admin123' }; |
| 45 | +const MEMBER = { email: 'pin-21516-member@example.invalid', password: 'Member-Pass-123' }; |
| 46 | + |
| 47 | +/** A table that exists at the driver and that no registry entry names. */ |
| 48 | +const UNREGISTERED = 'pin_offbook_21516'; |
| 49 | +/** The value the out-of-band row carries: what no answer below may contain. */ |
| 50 | +const SENTINEL = 'offbook-sentinel-21516'; |
| 51 | +/** The control row, written through the data door on the registered object. */ |
| 52 | +const CONTROL_TITLE = 'registered-control-21516'; |
| 53 | + |
| 54 | +const body = (source: string) => ({ language: 'js', source, capabilities: ['api.read'], timeoutMs: 5000 }); |
| 55 | + |
| 56 | +const PIN_APP: any = { |
| 57 | + manifest: { id: 'com.pin.unresolved21516', name: 'Unresolved name pins', version: '1.0.0' }, |
| 58 | + objects: [ |
| 59 | + { |
| 60 | + name: 'pin_note', |
| 61 | + label: 'Pin note', |
| 62 | + fields: { title: { type: 'text', label: 'Title' } }, |
| 63 | + actions: [ |
| 64 | + { |
| 65 | + name: 'body_reads_unregistered', |
| 66 | + label: 'Body reads an unregistered name', |
| 67 | + type: 'script', |
| 68 | + body: body(`const rows = await ctx.api.object('${UNREGISTERED}').find({});\nreturn { rows };`), |
| 69 | + }, |
| 70 | + { |
| 71 | + name: 'body_reads_registered', |
| 72 | + label: 'Body reads a registered name', |
| 73 | + type: 'script', |
| 74 | + body: body(`const rows = await ctx.api.object('pin_note').find({});\nreturn { rows };`), |
| 75 | + }, |
| 76 | + ], |
| 77 | + }, |
| 78 | + ], |
| 79 | + permissions: [ |
| 80 | + { |
| 81 | + name: 'pin_21516_member_default', |
| 82 | + label: 'Pin member default', |
| 83 | + isDefault: true, |
| 84 | + objects: { pin_note: { allowRead: true, allowCreate: true } }, |
| 85 | + }, |
| 86 | + ], |
| 87 | +}; |
| 88 | + |
| 89 | +let kernel: any; |
| 90 | +let httpServer: any; |
| 91 | +let app: any; |
| 92 | +let engine: any; |
| 93 | +let adminToken: string; |
| 94 | +let memberToken: string; |
| 95 | +let prevNodeEnv: string | undefined; |
| 96 | + |
| 97 | +const req = (path: string, init?: RequestInit) => app.request(`${ORIGIN}${API}${path}`, init); |
| 98 | +const as = (token: string | undefined, method: string, path: string, payload?: unknown) => |
| 99 | + req(path, { |
| 100 | + method, |
| 101 | + headers: { |
| 102 | + 'Content-Type': 'application/json', |
| 103 | + ...(token ? { Authorization: `Bearer ${token}` } : {}), |
| 104 | + }, |
| 105 | + ...(payload !== undefined ? { body: JSON.stringify(payload) } : {}), |
| 106 | + }); |
| 107 | + |
| 108 | +async function readJson(res: Response): Promise<any> { |
| 109 | + const text = await res.text(); |
| 110 | + try { |
| 111 | + return JSON.parse(text); |
| 112 | + } catch { |
| 113 | + return text; |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +/** The ADR-0112 code, wherever the door's envelope carries it. */ |
| 118 | +const codeOf = (payload: any): unknown => payload?.error?.code ?? payload?.code; |
| 119 | + |
| 120 | +async function signIn(who: { email: string; password: string }): Promise<string> { |
| 121 | + const res = await req('/auth/sign-in/email', { |
| 122 | + method: 'POST', |
| 123 | + headers: { 'Content-Type': 'application/json' }, |
| 124 | + body: JSON.stringify(who), |
| 125 | + }); |
| 126 | + if (!res.ok) throw new Error(`pin signIn failed: ${res.status} ${await res.text()}`); |
| 127 | + return (await res.json() as any).token; |
| 128 | +} |
| 129 | + |
| 130 | +async function signUpMember(): Promise<string> { |
| 131 | + // Default audience posture is invite_only: enter through a pending invitation, |
| 132 | + // the lane `@objectstack/verify`'s signUp takes. |
| 133 | + await engine.insert( |
| 134 | + 'sys_invitation', |
| 135 | + { |
| 136 | + id: 'inv_pin_21516', |
| 137 | + email: MEMBER.email, |
| 138 | + status: 'pending', |
| 139 | + organization_id: 'org_pin_21516', |
| 140 | + role: 'member', |
| 141 | + inviter_id: 'usr_pin_21516', |
| 142 | + expires_at: new Date(Date.now() + 3_600_000), |
| 143 | + }, |
| 144 | + { context: { isSystem: true } }, |
| 145 | + ); |
| 146 | + const res = await req('/auth/sign-up/email', { |
| 147 | + method: 'POST', |
| 148 | + headers: { 'Content-Type': 'application/json' }, |
| 149 | + body: JSON.stringify({ email: MEMBER.email, password: MEMBER.password, name: 'pin member' }), |
| 150 | + }); |
| 151 | + if (!res.ok) throw new Error(`pin signUp failed: ${res.status} ${await res.text()}`); |
| 152 | + return (await res.json() as any).token; |
| 153 | +} |
| 154 | + |
| 155 | +beforeAll(async () => { |
| 156 | + prevNodeEnv = process.env.NODE_ENV; |
| 157 | + process.env.NODE_ENV = 'development'; // the dev-admin seed, as `objectstack dev` / bootStack arm it |
| 158 | + |
| 159 | + kernel = new ObjectKernel(); |
| 160 | + await kernel.use(new ObjectQLPlugin()); |
| 161 | + await kernel.use(new DefaultDatasourcePlugin({ driver: 'sqlite-wasm', config: { filename: ':memory:' } })); |
| 162 | + await kernel.use(new HonoServerPlugin({ port: 0 })); |
| 163 | + await kernel.use(new AppPlugin(PIN_APP)); |
| 164 | + await kernel.use(new PlatformObjectsPlugin()); |
| 165 | + await kernel.use(new AuthPlugin({ secret: 'unresolved-name-21516-secret', autoDefaultOrganization: false })); |
| 166 | + await kernel.use(new SecurityPlugin(appSecurityPluginOptions(PIN_APP))); |
| 167 | + await kernel.use(new SharingServicePlugin()); |
| 168 | + await kernel.use(createRestApiPlugin({})); |
| 169 | + await kernel.use(createDispatcherPlugin({})); |
| 170 | + await kernel.bootstrap(); |
| 171 | + |
| 172 | + httpServer = await kernel.getServiceAsync('http-server'); |
| 173 | + app = httpServer.getRawApp(); |
| 174 | + engine = await kernel.getServiceAsync('objectql'); |
| 175 | + |
| 176 | + // The out-of-band table, created and written at the DRIVER (host code's |
| 177 | + // declared internal path), and registered nowhere. |
| 178 | + const driver = engine.getDriverByName(engine.getDefaultDriverName()); |
| 179 | + await driver.syncSchema(UNREGISTERED, { |
| 180 | + name: UNREGISTERED, |
| 181 | + fields: { id: { name: 'id', type: 'text', primaryKey: true }, secret: { name: 'secret', type: 'text' } }, |
| 182 | + }); |
| 183 | + await driver.create(UNREGISTERED, { id: 'offbook_1', secret: SENTINEL }); |
| 184 | + |
| 185 | + adminToken = await signIn(ADMIN); |
| 186 | + memberToken = await signUpMember(); |
| 187 | + const seeded = await as(adminToken, 'POST', '/data/pin_note', { title: CONTROL_TITLE }); |
| 188 | + if (seeded.status >= 300) throw new Error(`pin seed refused: ${seeded.status} ${JSON.stringify(await readJson(seeded))}`); |
| 189 | +}, BOOT_TIMEOUT); |
| 190 | + |
| 191 | +afterAll(async () => { |
| 192 | + try { await httpServer?.close?.(); } catch { /* best-effort */ } |
| 193 | + try { await kernel?.shutdown?.(); } catch { /* best-effort */ } |
| 194 | + if (prevNodeEnv === undefined) delete process.env.NODE_ENV; |
| 195 | + else process.env.NODE_ENV = prevNodeEnv; |
| 196 | +}, 60_000); |
| 197 | + |
| 198 | +describe('[#21516] precondition and reference', () => { |
| 199 | + it('the table exists at the driver and holds the row; no registry entry names it', async () => { |
| 200 | + expect(engine.registry.getObject(UNREGISTERED)).toBeUndefined(); |
| 201 | + const driver = engine.getDriverByName(engine.getDefaultDriverName()); |
| 202 | + const rows: any[] = await driver.find(UNREGISTERED, {}); |
| 203 | + expect(rows.map((r) => r.secret)).toEqual([SENTINEL]); |
| 204 | + }); |
| 205 | + |
| 206 | + it('the generic data door answers 404 OBJECT_NOT_FOUND for the same name', async () => { |
| 207 | + const res = await as(adminToken, 'GET', `/data/${UNREGISTERED}`); |
| 208 | + const payload = await readJson(res); |
| 209 | + expect(res.status).toBe(404); |
| 210 | + expect(codeOf(payload)).toBe('OBJECT_NOT_FOUND'); |
| 211 | + expect(JSON.stringify(payload)).not.toContain(SENTINEL); |
| 212 | + }); |
| 213 | +}); |
| 214 | + |
| 215 | +describe('[#21516] an action body via /actions reading a name the registry does not resolve', () => { |
| 216 | + for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { |
| 217 | + it(`invoked by the ${role}: answers not-found, and nothing of the table reaches the answer`, async () => { |
| 218 | + const res = await as(token(), 'POST', '/actions/pin_note/body_reads_unregistered', { params: {} }); |
| 219 | + const payload = await readJson(res); |
| 220 | + expect({ status: res.status, code: codeOf(payload) }).toEqual({ status: 404, code: 'OBJECT_NOT_FOUND' }); |
| 221 | + expect(JSON.stringify(payload)).not.toContain(SENTINEL); |
| 222 | + }); |
| 223 | + } |
| 224 | +}); |
| 225 | + |
| 226 | +describe('[#21516] CONTROL — the same body shape on a registered name', () => { |
| 227 | + for (const [role, token] of [['administrator', () => adminToken], ['member', () => memberToken]] as const) { |
| 228 | + it(`invoked by the ${role}: served`, async () => { |
| 229 | + const res = await as(token(), 'POST', '/actions/pin_note/body_reads_registered', { params: {} }); |
| 230 | + const payload = await readJson(res); |
| 231 | + expect(res.status).toBe(200); |
| 232 | + expect(JSON.stringify(payload)).toContain(CONTROL_TITLE); |
| 233 | + }); |
| 234 | + } |
| 235 | +}); |
0 commit comments