@isomorph.ai/app-sdk 2.8.1 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -2
- package/dist/runtime.d.ts +5 -0
- package/dist/runtime.js +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -370,12 +370,37 @@ The calls go to the local App Gateway beside the runtime (`ISOMORPH_TRUSTED_GATE
|
|
|
370
370
|
One door with nobody signed in: a sign-up form, a feedback box, an inbound lead. An action that
|
|
371
371
|
exports `access = "public"` beside `expose = { description, input }` runs for a caller with no
|
|
372
372
|
sign-in as `public:<app>` (`ctx.user.kind === 'public'`, no email, no groups). It may only insert
|
|
373
|
-
rows
|
|
373
|
+
rows, or call a SQL function named `public_<name>` through `isomorph.data.rpc`: a select, update,
|
|
374
|
+
delete, any other function, file, integration, AI call, realtime, another action or a job
|
|
374
375
|
enqueue inside it is refused with `PUBLIC_DENIED`. A public action cannot be trusted. The page
|
|
375
376
|
calls it with `isomorph.actions.invokePublic(name, input)`: a plain POST with no cookie and no
|
|
376
377
|
sign-in, answering the result or an `IsomorphError` with the gateway's category. The table it
|
|
377
378
|
writes says so in its row rule (`WITH CHECK (current_setting('harbour.user_id', true) = 'public:<app>')`).
|
|
378
379
|
|
|
380
|
+
A page that shows one record by its link (a certificate, a ticket) reads it through one
|
|
381
|
+
`public_<name>` function the app declares `SECURITY DEFINER`, taking the link's token and
|
|
382
|
+
returning only what the page may show; the gateway runs it read-only. No public `SELECT` policy
|
|
383
|
+
is ever written.
|
|
384
|
+
|
|
385
|
+
```sql
|
|
386
|
+
-- migrations/0012_public_certificate.sql
|
|
387
|
+
CREATE FUNCTION public_certificate(token text) RETURNS TABLE (holder text, course text, issued_on date)
|
|
388
|
+
LANGUAGE sql SECURITY DEFINER STABLE AS $$
|
|
389
|
+
SELECT holder, course, issued_on FROM certificates WHERE verify_token = token
|
|
390
|
+
$$;
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
// actions/verify-certificate.ts
|
|
395
|
+
export const access = "public";
|
|
396
|
+
export const expose = { description: 'Show the certificate a verification link points at', input: { type: 'object', properties: { token: { type: 'string' } }, required: ['token'] } };
|
|
397
|
+
|
|
398
|
+
export default async function verifyCertificate(input: { token: string }) {
|
|
399
|
+
const [certificate] = await isomorph.data.rpc<Array<{ holder: string; course: string; issued_on: string }>>('public_certificate', { token: input.token });
|
|
400
|
+
return certificate ?? null;
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
379
404
|
```ts
|
|
380
405
|
// actions/signup.ts
|
|
381
406
|
export const access = "public";
|
|
@@ -390,7 +415,7 @@ export default async function signup(input: { email: string }) {
|
|
|
390
415
|
await isomorph.actions.invokePublic('signup', { email });
|
|
391
416
|
```
|
|
392
417
|
|
|
393
|
-
(2.6.0)
|
|
418
|
+
(2.6.0; public read by link 2.9.0, data plane ADR 0114 amendment 2026-11-07)
|
|
394
419
|
|
|
395
420
|
## Calling another app as the signed-in person
|
|
396
421
|
|
|
@@ -408,6 +433,10 @@ The other app (named by its address, as `appApiUrl` takes it) lists this one in
|
|
|
408
433
|
trusted action; name a trusted action exactly. Its action sees the person in `ctx.user` (role `member`) and this app
|
|
409
434
|
in `ctx.caller = { app, environment }` (`app` is the calling app's id; `local` under `isomorph dev` and
|
|
410
435
|
`isomorph check`, where `app` is the calling address). `ctx.caller` is absent on its own pages, service and MCP calls.
|
|
436
|
+
|
|
437
|
+
Every action also sees `ctx.clientIp`: the caller's network address as the gateway in front of
|
|
438
|
+
the app saw it, for a sign-in record or a per-address rate limit (2.9.0). Nothing the browser
|
|
439
|
+
sends sets it.
|
|
411
440
|
The call goes through this app's own gateway with the workload token and the person's context; a refusal is an
|
|
412
441
|
`IsomorphError` with the gateway's category (`AUTH_REQUIRED`, `FORBIDDEN` when not listed, `NOT_FOUND`,
|
|
413
442
|
`RATE_LIMITED`). It works only from an action a signed-in person started: a job keeps using a service credential.
|
package/dist/runtime.d.ts
CHANGED
|
@@ -18,6 +18,11 @@ export interface ActionContext {
|
|
|
18
18
|
tenant: string;
|
|
19
19
|
app: string;
|
|
20
20
|
requestId: string;
|
|
21
|
+
/**
|
|
22
|
+
* The caller's network address as the gateway saw it (`X-Harbour-Verified-Client-IP`): for a
|
|
23
|
+
* sign-in record or a rate limit. Absent under a runtime behind an older gateway.
|
|
24
|
+
*/
|
|
25
|
+
clientIp?: string;
|
|
21
26
|
/**
|
|
22
27
|
* Reports a step to a page that invoked the action with `onProgress`; a
|
|
23
28
|
* no-op otherwise. A string or a small JSON object; after 256 steps, or a
|
package/dist/runtime.js
CHANGED
|
@@ -525,7 +525,7 @@ export class Runtime {
|
|
|
525
525
|
fail(404, 'NOT_FOUND', `${JOBS_DIR}/${jobName}.ts expects deliveries from ${trigger.provider}, not ${provider}`);
|
|
526
526
|
const secret = trigger.secret ? process.env[trigger.secret] : undefined;
|
|
527
527
|
if (trigger.secret && !secret)
|
|
528
|
-
fail(500, 'INTERNAL', `the app secret ${trigger.secret} that ${JOBS_DIR}/${jobName}.ts names for ${trigger.provider} is not set; set it on the app's secrets page`);
|
|
528
|
+
fail(500, 'INTERNAL', `the app secret ${trigger.secret} that ${JOBS_DIR}/${jobName}.ts names for ${trigger.provider} is not set in this running version; set it on the app's secrets page, and a value saved there applies at the app's next deploy`);
|
|
529
529
|
const verdict = WEBHOOK_ADAPTERS[trigger.provider]({ headers, rawBody, secret, now: Date.now() });
|
|
530
530
|
if (verdict.kind === 'challenge')
|
|
531
531
|
return { challenge: verdict.challenge };
|
|
@@ -816,7 +816,8 @@ export class Runtime {
|
|
|
816
816
|
response.write(line);
|
|
817
817
|
};
|
|
818
818
|
const caller = verifiedCaller(request);
|
|
819
|
-
const
|
|
819
|
+
const clientIp = header(request, 'X-Harbour-Verified-Client-IP');
|
|
820
|
+
const context = { computerView: header(request, 'X-Harbour-Computer-View') === '1', signal: controller.signal, user, tenant, app, requestId: requestId ?? '', ...(clientIp ? { clientIp } : {}), progress, transaction: (fn) => this.transaction(name, fn), ...(caller ? { caller } : {}) };
|
|
820
821
|
const limit = caller ? APP_CALL_MAX_BODY_BYTES : MAX_BODY_BYTES;
|
|
821
822
|
const raw = await readBody(request, limit);
|
|
822
823
|
if (raw === undefined)
|