@sequenceholdings/artifact-studio 0.6.3 → 0.6.6

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.
Files changed (78) hide show
  1. package/README.md +183 -6
  2. package/VERSION-PIN.md +56 -1
  3. package/dist/gateway/admission.d.ts +23 -1
  4. package/dist/gateway/admission.js +30 -13
  5. package/dist/gateway/control.d.ts +40 -0
  6. package/dist/gateway/control.js +76 -7
  7. package/dist/gateway/hosted-proxy.d.ts +11 -8
  8. package/dist/gateway/hosted-proxy.js +99 -91
  9. package/dist/gateway/ingress-admission.d.ts +77 -0
  10. package/dist/gateway/ingress-admission.js +150 -0
  11. package/dist/gateway/ingress-audit.d.ts +46 -0
  12. package/dist/gateway/ingress-audit.js +84 -0
  13. package/dist/gateway/ingress-context.d.ts +57 -0
  14. package/dist/gateway/ingress-context.js +57 -0
  15. package/dist/gateway/ingress-development.d.ts +12 -0
  16. package/dist/gateway/ingress-development.js +23 -0
  17. package/dist/gateway/ingress-handler.d.ts +44 -0
  18. package/dist/gateway/ingress-handler.js +320 -0
  19. package/dist/gateway/ingress-pass.d.ts +83 -0
  20. package/dist/gateway/ingress-pass.js +143 -0
  21. package/dist/gateway/ingress-response.d.ts +74 -0
  22. package/dist/gateway/ingress-response.js +98 -0
  23. package/dist/gateway/ingress-runtime.d.ts +86 -0
  24. package/dist/gateway/ingress-runtime.js +126 -0
  25. package/dist/gateway/ingress-snapshot.d.ts +54 -0
  26. package/dist/gateway/ingress-snapshot.js +63 -0
  27. package/dist/gateway/ingress-stages.d.ts +60 -0
  28. package/dist/gateway/ingress-stages.js +387 -0
  29. package/dist/gateway/ingress.d.ts +259 -0
  30. package/dist/gateway/ingress.js +204 -0
  31. package/dist/gateway/lattice-invoke.js +2 -0
  32. package/dist/gateway/lattice-upstream.d.ts +3 -4
  33. package/dist/gateway/lattice-upstream.js +11 -9
  34. package/dist/gateway/runtime-transport.d.ts +17 -0
  35. package/dist/gateway/runtime-transport.js +26 -0
  36. package/dist/gateway/server.d.ts +3 -0
  37. package/dist/gateway/server.js +21 -0
  38. package/dist/gateway/session-response.d.ts +15 -0
  39. package/dist/gateway/session-response.js +40 -0
  40. package/dist/gateway/usage.d.ts +8 -1
  41. package/dist/gateway-main.js +32 -8
  42. package/dist/manifest-next-ingress.d.ts +5 -2
  43. package/dist/manifest-next-ingress.js +27 -2
  44. package/dist/manifest-next.d.ts +20 -6
  45. package/dist/manifest-next.js +13 -5
  46. package/dist/manifest.d.ts +7 -2
  47. package/dist/next-project.d.ts +4 -0
  48. package/dist/sdk.d.ts +12 -3
  49. package/dist/server/broker-contract.d.ts +62 -0
  50. package/dist/server/broker-contract.js +43 -1
  51. package/dist/server/client.js +38 -13
  52. package/dist/server/external-context.d.ts +8 -1
  53. package/dist/server/external-context.js +7 -2
  54. package/dist/server/internal/v1-context.js +17 -1
  55. package/dist/server/internal/verify-any.d.ts +11 -2
  56. package/dist/server/internal/verify-any.js +11 -3
  57. package/dist/server/process-run-sharing.d.ts +86 -0
  58. package/dist/server/process-run-sharing.js +29 -0
  59. package/dist/server/process-runs-page.d.ts +39 -0
  60. package/dist/server/process-runs-page.js +10 -0
  61. package/dist/server/processes.d.ts +8 -9
  62. package/dist/server/processes.js +4 -0
  63. package/dist/server/request-context.js +10 -3
  64. package/dist/server/runtime-config.d.ts +10 -1
  65. package/dist/server/runtime-config.js +11 -1
  66. package/dist/server/testing.d.ts +1 -1
  67. package/dist/server.d.ts +2 -2
  68. package/dist/server.js +1 -1
  69. package/dist/tunnel/broker.d.ts +18 -0
  70. package/dist/tunnel/broker.js +175 -17
  71. package/dist/tunnel/client.d.ts +13 -0
  72. package/dist/tunnel/client.js +40 -2
  73. package/dist/tunnel/external.d.ts +51 -0
  74. package/dist/tunnel/external.js +10 -0
  75. package/dist/tunnel/index.d.ts +1 -1
  76. package/dist/tunnel/protocol.d.ts +20 -2
  77. package/dist/tunnel/protocol.js +42 -6
  78. package/package.json +4 -4
package/README.md CHANGED
@@ -64,6 +64,142 @@ deployer provide generic application capabilities; each deployed artifact owns
64
64
  its product behavior. Primitive code must not branch on an artifact ID or import
65
65
  an application's services. See [Platform primitive boundaries](../../../docs/platform/platform-primitives.md).
66
66
 
67
+ ## Lattice run lists and collaboration (internal prerelease)
68
+
69
+ The server entry point remains an **internal** surface. These methods describe
70
+ workspace source awaiting a matching SDK release and platform deployment;
71
+ they are not a newly supported public SDK or direct HTTP contract. See
72
+ [VERSION-PIN.md](./VERSION-PIN.md) for compatibility requirements.
73
+
74
+ On the request-scoped client returned by `createArtifactServerClient`, declare
75
+ the exact process IDs the application reads or acts on:
76
+
77
+ ```yaml
78
+ server:
79
+ capabilities:
80
+ processes:
81
+ read: [review-process]
82
+ act: [review-process]
83
+ ```
84
+
85
+ `client.processes.listRunsPage` returns compact summaries for activity lists:
86
+
87
+ ```ts
88
+ const page = await client.processes.listRunsPage({
89
+ process: 'review-process',
90
+ statuses: ['pending', 'running'],
91
+ entity: { type: 'application', ids: ['example-application-1'] },
92
+ limit: 100,
93
+ })
94
+ // page: { runs: ArtifactProcessRunSummary[], nextCursor: string | null }
95
+ ```
96
+
97
+ Each summary contains `id`, `processId`, `status`, `entityType`, `entityId`,
98
+ `startedAt`, `completedAt` and `error`; it does not fetch run state, output or
99
+ step payloads. Use `getRun` for detail. `statuses` is an optional, nonempty set
100
+ of run statuses. `entity` accepts a type alone, one `id`, or 1–500 `ids`; `id`
101
+ and `ids` are mutually exclusive. `archived: true` selects only archived runs;
102
+ the default excludes them. The existing `listRuns` contract is unchanged.
103
+
104
+ The caller's run Viewer permission filters the results. `limit` bounds visible
105
+ rows (default 100, maximum 500). Pass `nextCursor` back as `cursor` with the
106
+ same process, status, entity and archive filters; the limit may change. The
107
+ encrypted cursor is bound to the caller and expires after one hour. It is
108
+ returned only when another visible row exists and resumes after the last
109
+ returned visible row. A successful empty page has `nextCursor: null`.
110
+
111
+ The platform establishes the caller's complete authorization scope before
112
+ selecting run rows. Unavailable authorization, an unsupported authorization
113
+ model, or an incomplete grant scope rejects the call with 503 and no partial
114
+ page. A 503 must not be treated as an empty list. Invalid, expired, or
115
+ filter-mismatched cursors reject with 400. These reads work from read-only and
116
+ stale request contexts.
117
+
118
+ `client.processes.shareRun` adds collaborators to selected human nodes while
119
+ preserving their existing assignments, and grants those recipients run viewing:
120
+
121
+ ```ts
122
+ const shared = await client.processes.shareRun({
123
+ runId: 'example-run-1',
124
+ nodeIds: ['review', 'reopen'],
125
+ users: ['auth0|example-reviewer'],
126
+ })
127
+ // shared: { run_id, nodes_updated, users, groups, authorization_applied }
128
+ ```
129
+
130
+ This requires `processes.act`, a signed-in human caller, and Owner on the run
131
+ unless the caller explicitly uses an onward-sharing authority below. It is
132
+ refused in service mode, from external callers, and from read-only or stale
133
+ request contexts. Recipients gain full-run viewing, so a node's delegation
134
+ permission alone does not authorize this operation. Authored assignment
135
+ allowlists still constrain owners, and disabled users or deleted groups cannot
136
+ receive access.
137
+
138
+ Name 1–100 human nodes and 1–100 users and groups combined. Node IDs are
139
+ 1–255 characters and cannot contain the reserved `::` separator. Each target
140
+ node must already have an explicit assignment on this run; missing assignments are
141
+ rejected rather than replacing authored or dynamic defaults. Sharing preserves
142
+ existing assignees, is additive on retries, and never adds the caller implicitly.
143
+ Terminal runs are refused with 410. Use `assign` to replace one parked step's
144
+ assignees, or `grantViewers` to grant viewing without participation.
145
+
146
+ If sharing was saved but authorization delivery is pending, the call rejects
147
+ with 503 and `SHARE_PENDING`. Retry the same share before changing the
148
+ application's own task ownership; an error here does not mean the saved sharing
149
+ intent was rolled back.
150
+
151
+ ### Explicit onward sharing
152
+
153
+ A run owner can also consent to recipients sharing full-run viewing and the
154
+ same or fewer human nodes onward, within this application's live runtime
155
+ audience:
156
+
157
+ ```ts
158
+ const shared = await client.processes.shareRun({
159
+ runId: 'example-run-1',
160
+ nodeIds: ['review', 'reopen'],
161
+ users: ['auth0|example-reviewer'],
162
+ delegateSharing: { audience: 'current_app' },
163
+ })
164
+ // Check shared.authorization_applied and shared.sharingAuthorityId before changing task ownership.
165
+ ```
166
+
167
+ This option accepts individual users only; groups cannot receive delegated
168
+ sharing authority. The platform resolves `current_app` from the admitted
169
+ request, and checks the current application audience on every share. The
170
+ authority is pinned to the run, its process bundle and the allowed nodes.
171
+ Each onward recipient remains bounded by that authority and cannot expand the
172
+ audience or node set. Delegates must pass `delegateSharing` explicitly on each
173
+ onward share; omitting it retains the ordinary Owner-only behavior. Owning or
174
+ viewing one human step does not itself grant this authority.
175
+
176
+ Run owners can inspect and revoke root authorities through the same
177
+ `processes.act` declaration, in user mode:
178
+
179
+ ```ts
180
+ const page = await client.processes.listRunSharingAuthorities({
181
+ runId: 'example-run-1',
182
+ limit: 20,
183
+ })
184
+ // Follow page.nextCursor with the same runId until null.
185
+ const authority = page.authorities[0]
186
+ if (authority) {
187
+ await client.processes.revokeRunSharingAuthority({
188
+ runId: 'example-run-1',
189
+ authorityId: authority.id,
190
+ })
191
+ }
192
+ ```
193
+
194
+ Authority pages default to 20 records, at most 50. Each record includes its
195
+ run, bundle, node scope, application audience, creator, creation/revocation
196
+ timestamps and up to 100 delegates, including who granted each delegation and
197
+ when. Record fields use the platform's snake case; the page exposes
198
+ `nextCursor`. Listing works from stale/read-only contexts; revocation requires
199
+ a current writable request. Revocation is idempotent and returns
200
+ `{ authority_id, revoked: true }`. Revoking a root stops onward sharing through
201
+ every delegate under it; existing run viewing and step participation remain.
202
+
67
203
  ## Lost connections in a Next.js server
68
204
 
69
205
  The server client (`@sequenceholdings/artifact-studio/server`) sends a read up
@@ -72,12 +208,53 @@ connection was refused or reset, the host or network was unreachable, or a DNS
72
208
  lookup failed transiently. It pauses up to 100 ms, then up to 200 ms, and logs a
73
209
  `broker.reconnect` warning for each resend. Reads are `client.orm` operations
74
210
  the manifest lists with `kind: 'query'`, `client.api` GET and HEAD requests,
75
- Storage GET and HEAD requests, and flag evaluations. ORM mutations, other API
76
- methods, Storage writes, function invokes and process starts are sent once,
77
- because a reset can come after Atlas acted on them, and reject with fetch's
78
- `TypeError: fetch failed` as before. (Audit delivery keeps its own single
79
- resend under the same event id.) A response with any status is Atlas's answer
80
- and is never resent.
211
+ Storage GET and HEAD requests, flag evaluations, and ORM mutations sent with an
212
+ idempotency key (below). Other ORM mutations, other API methods, Storage
213
+ writes, function invokes and process starts are sent once, because a reset can
214
+ come after Atlas acted on them, and reject with fetch's `TypeError: fetch
215
+ failed` as before. (Audit delivery keeps its own single resend under the same
216
+ event id.) A response with any status is Atlas's answer and is never resent.
217
+ To retry an ORM mutation yourself, pass `{ idempotencyKey }` as the third
218
+ argument of `orm(...).execute`; the data plane returns the first attempt's
219
+ receipt for the same key and body (a key on a query is refused).
220
+
221
+ ```ts
222
+ // Mutations only: a key on a manifest-listed query throws 400 `idempotency_key_on_query`.
223
+ await db.execute('SaveApplication', variables, { idempotencyKey: `save:${applicationId}:${draftId}` })
224
+ ```
225
+
226
+ Keys are 1-128 letters, digits, dots, underscores, colons or hyphens; anything
227
+ else throws 400 `invalid_idempotency_key` before a call is made. An Atlas build
228
+ that predates the key rejects it with a 400, so pass one only to an Atlas that
229
+ includes it (see [VERSION-PIN.md](./VERSION-PIN.md)).
230
+
231
+ ## Previewing a retained Storage file in the browser
232
+
233
+ An Embed app can use the host's shared PDF/image dialog without adding its own
234
+ PDF worker or changing sandbox CSP. Declare the workspace under browser
235
+ `capabilities.storage` with at least `viewer` reach, then call:
236
+
237
+ ```ts
238
+ if (seq.storage.preview) {
239
+ await seq.storage.preview({ workspaceId, fileId, versionId, filename, mime: 'application/pdf' })
240
+ }
241
+ ```
242
+
243
+ `versionId` is required: previews never silently switch to the latest file.
244
+ The manifest limits reach; it does not grant access. Atlas requests the download
245
+ lease as the signed-in user and Storage checks their permissions and the
246
+ file/version association. No URL or bytes are accepted from the app or returned
247
+ by this method. `filename` and `mime` are display hints; supported MIME types are
248
+ `application/pdf`, `image/jpeg`, `image/png`, `image/gif`, and `image/webp`.
249
+ The promise resolves `{ opened: true }` when the dialog opens; loading/access
250
+ errors appear there. Close and reopen to retry or refresh an expired lease.
251
+
252
+ Next.js browser integrations can make the same host request through
253
+ `bridge.request({ method: 'storage.preview', args: [input] })`; this is a browser
254
+ UI operation, never a server SDK operation or service-account read. It requires
255
+ an Atlas build with Storage preview support. Older Embed hosts omit
256
+ `seq.storage.preview`; feature-detect it and retain an external-open fallback
257
+ until the host rollout completes. Host/session replacement closes the preview.
81
258
 
82
259
  ## Calling agents from a Next.js server
83
260
 
package/VERSION-PIN.md CHANGED
@@ -33,16 +33,53 @@ refuse a v2 context, or any token signed with an `external-` kid, with reason
33
33
  through `verifyArtifactExternalContext` or `createArtifactServerClient({ accept })`,
34
34
  and an external principal reaches platform services only through `asService()`.
35
35
  `@sequenceholdings/artifact-studio/server/testing` signs test contexts.
36
+ The server client's `orm(...).execute(op, variables, { idempotencyKey })` sends
37
+ an idempotency key (the data plane's `^[A-Za-z0-9._:-]{1,128}$`) with a
38
+ mutation, so a retry returns the first attempt's receipt; a key on a query is
39
+ refused. Atlas scopes an external caller's key to that caller before the data
40
+ plane sees it, so callers sharing the ingress service identity never share a
41
+ receipt. Because a resend carries the same key and body, the server client also
42
+ resends a keyed mutation once its connection to Atlas fails before an answer,
43
+ as it does a read. Atlas builds without this change reject `idempotencyKey`
44
+ (400); pass a key only once the target Atlas includes it, and publish 0.6.0
45
+ from a commit that contains it.
36
46
  0.6.0 also carries `error.atlas` (`{ code, detail }`) on a refused broker call
37
47
  (#13398); a step-up 401 still surfaces as `insufficient_user_authentication`.
38
48
 
39
49
  0.6.0 also accepts the `server.ingress` manifest block. Earlier CLIs and Atlas
40
50
  versions parse the server block strictly and reject the key, so an app that
41
- declares an ingress needs 0.6.0 on both sides; that failure is closed. An
51
+ declares an ingress needs 0.6.0 on both sides; that failure is closed.
52
+ An ingress names every ORM operation the application may run through the
53
+ broker for an external caller: reads in `capabilities.data.operations` and
54
+ writes in `capabilities.data.write`, each as `namespace/ops/<Operation>` (no
55
+ globs, in a namespace the ingress's own data reaches). Atlas refuses an
56
+ external caller's call to any other persisted operation, query or mutation,
57
+ with 403 `operation_not_allowed`, before the data plane's table check;
58
+ `data.operations` defaults to none, so an ingress that lists no reads runs no
59
+ queries. The list does not bound a Managed Function the ingress invokes: that
60
+ function runs with its own data credential. User and v1 service-mode calls
61
+ keep table-set admission. An
42
62
  external context binds the canonical route path (`server/ingress-routes`): a
43
63
  handler's `path` is canonicalised the same way before it is compared, and a
44
64
  signed path that is not canonical is refused.
45
65
 
66
+ An external context in `development` mode (local `artifact dev` only) is
67
+ accepted only when the runtime configuration came from a development lease:
68
+ the owner-only file `artifact dev` names in `SEQUENCE_ARTIFACT_RUNTIME_FILE`,
69
+ exposed as `runtimeConfig.development === true`, and only for that lease's
70
+ session. The marker is never parsed from JSON, so a hosted runtime
71
+ (`SEQUENCE_ARTIFACT_RUNTIME_CONFIG`), the lease file's content and an explicit
72
+ `runtime` option all refuse development contexts. A gateway proxies a
73
+ development target only when its own `APP_ENV` is `development`.
74
+
75
+ Over the development tunnel the app sees the ingress host as `host` and
76
+ `x-forwarded-host`, as it sees the ingress origin on a hosted release: the
77
+ broker sets it, and the tunnel client keeps it only on a frame whose context is
78
+ signed by an `external-` kid. A tunnel client from an SDK before this change
79
+ pins every request, ingress ones included, to the session origin
80
+ (`<session>.apps.localhost`), so app code that builds absolute URLs from the
81
+ host behaves differently locally until the CLI is upgraded.
82
+
46
83
  Context version policy: a context verifier rejects any unknown top-level or
47
84
  `principal` key unless it sits under `ext` or is named in `crit` (and `crit`
48
85
  names only extensions the verifier understands; today none, so it is empty).
@@ -57,6 +94,24 @@ before publication; an ordinary released CLI intentionally cannot.
57
94
  The React/Vite starter therefore uses released primitives and responsive CSS
58
95
  until that pin advances. Its build test exercises the ordinary CLI toolchain.
59
96
 
97
+ ## Unreleased server process additions
98
+
99
+ Workspace source adds `processes.listRunsPage` (visible, paginated run summaries)
100
+ and `processes.shareRun` (additive collaboration on explicitly assigned human
101
+ nodes, with owner-consented onward sharing scoped to the current application).
102
+ The owner-only `listRunSharingAuthorities` and `revokeRunSharingAuthority`
103
+ methods inspect and stop that onward authority. These methods need an SDK
104
+ published from this change and a target platform deployment containing their
105
+ matching handlers. An SDK version string
106
+ alone does not establish that the deployed platform supports them; earlier
107
+ platform builds reject the new operations. See the SDK README for their input,
108
+ pagination, authorization and retry contracts.
109
+
110
+ These additions use the existing `./server` and `./server/broker-contract`
111
+ entry points, which remain classified **internal**. They add no package export
112
+ subpath or supported direct HTTP surface. Publication and a reviewed public
113
+ contract decision are separate from local build verification.
114
+
60
115
  ## What this means
61
116
 
62
117
  `shared/services/artifact-studio/src/build.ts` force-aliases platform peers via
@@ -37,6 +37,22 @@ export declare class ArtifactAdmissionError extends Error {
37
37
  readonly retryAfterSeconds?: number | undefined;
38
38
  constructor(status: 429 | 503, code: 'capacity_exceeded' | 'admission_unavailable', retryAfterSeconds?: number | undefined);
39
39
  }
40
+ /** Per-request concurrency an external ingress adds on top of the app's (design "Steady state" step 7). */
41
+ export declare const INGRESS_CONCURRENCY: Readonly<{
42
+ perSubject: 4;
43
+ perClient: 8;
44
+ appShare: 0.5;
45
+ }>;
46
+ /** An external ingress request: its share of the app, and per subject and client caps. */
47
+ export interface AdmissionIngress {
48
+ id: string;
49
+ /** `sha256(subject)`, lowercase hex. */
50
+ subjectHash: string;
51
+ /** `ingressClientBucket(clientIp)`. */
52
+ clientBucket: string;
53
+ }
54
+ /** The ingress's share of the app: half its capacity, and never zero. */
55
+ export declare function ingressConcurrencyShare(appMaximum: number): number;
40
56
  /** Fail-closed admission with no command retries. An uncertain acquisition is
41
57
  * never used; its unique permit expires even if Redis accepted it before loss. */
42
58
  export declare class ArtifactRequestAdmission {
@@ -51,6 +67,12 @@ export declare class ArtifactRequestAdmission {
51
67
  kind: 'admission_release_failed' | 'admission_timeout_callback_failed';
52
68
  }) => void;
53
69
  });
54
- acquire(target: AdmissionTarget): Promise<AdmissionLease>;
70
+ /**
71
+ * `ingress` adds the external ingress keys: the ingress's share of the app, 4 per subject and 8
72
+ * per client bucket, in the same atomic acquisition as the app and deployment keys.
73
+ */
74
+ acquire(target: AdmissionTarget, { ingress }?: {
75
+ ingress?: AdmissionIngress;
76
+ }): Promise<AdmissionLease>;
55
77
  }
56
78
  export {};
@@ -1,7 +1,7 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { performance } from 'node:perf_hooks';
3
3
  import { z } from 'zod';
4
- import { artifactNextResourceLimitsSchema } from '../manifest-next.js';
4
+ import { artifactNextIngressIdSchema, artifactNextResourceLimitsSchema } from '../manifest-next.js';
5
5
  import { redisNamespaceSchema } from '../tunnel/redis-ownership.js';
6
6
  export class ArtifactAdmissionError extends Error {
7
7
  status;
@@ -24,16 +24,16 @@ const resources = z.object({
24
24
  max_instances: resourceFields.max_instances,
25
25
  timeout_seconds: resourceFields.timeout_seconds,
26
26
  });
27
- // Both keys have the same Redis Cluster hash tag. A lease can never exist in
28
- // only one set, and previews/retained releases consume the same application cap.
29
- const ACQUIRE = `-- artifact-request-admission-acquire-v1
27
+ // Every key has the same Redis Cluster hash tag. A lease can never exist in only some of the sets,
28
+ // and previews/retained releases consume the same application cap. ARGV[2..n+1] are the per-key
29
+ // maxima (an ingress shares the app's capacity with stricter keys of its own); ARGV[n+2] the timeout.
30
+ const ACQUIRE = `-- artifact-request-admission-acquire-v2
30
31
  local time = redis.call('TIME')
31
32
  local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
32
- local maximum = tonumber(ARGV[2])
33
- local deadline = now + tonumber(ARGV[3])
34
- for _, key in ipairs(KEYS) do
33
+ local deadline = now + tonumber(ARGV[#KEYS + 2])
34
+ for index, key in ipairs(KEYS) do
35
35
  redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
36
- if redis.call('ZCARD', key) >= maximum then return {0, now} end
36
+ if redis.call('ZCARD', key) >= tonumber(ARGV[index + 1]) then return {0, now} end
37
37
  end
38
38
  for _, key in ipairs(KEYS) do
39
39
  redis.call('ZADD', key, deadline, ARGV[1])
@@ -41,6 +41,13 @@ for _, key in ipairs(KEYS) do
41
41
  redis.call('PEXPIRE', key, tonumber(latest[2]) - now + 1000)
42
42
  end
43
43
  return {1, deadline}`;
44
+ /** Per-request concurrency an external ingress adds on top of the app's (design "Steady state" step 7). */
45
+ export const INGRESS_CONCURRENCY = Object.freeze({ perSubject: 4, perClient: 8, appShare: 0.5 });
46
+ /** The ingress's share of the app: half its capacity, and never zero. */
47
+ export function ingressConcurrencyShare(appMaximum) {
48
+ return Math.max(1, Math.floor(INGRESS_CONCURRENCY.appShare * appMaximum));
49
+ }
50
+ const subjectHashSchema = z.string().regex(/^[0-9a-f]{64}$/);
44
51
  const RELEASE = `-- artifact-request-admission-release-v1
45
52
  for _, key in ipairs(KEYS) do
46
53
  redis.call('ZREM', key, ARGV[1])
@@ -62,7 +69,11 @@ export class ArtifactRequestAdmission {
62
69
  throw new Error('Admission Redis must disable offline queues, command replay and retries');
63
70
  }
64
71
  }
65
- async acquire(target) {
72
+ /**
73
+ * `ingress` adds the external ingress keys: the ingress's share of the app, 4 per subject and 8
74
+ * per client bucket, in the same atomic acquisition as the app and deployment keys.
75
+ */
76
+ async acquire(target, { ingress } = {}) {
66
77
  if (namespace.parse(target.environment) !== this.namespace)
67
78
  throw new Error('Admission environment mismatch');
68
79
  const projectId = identifier.parse(target.projectId), deploymentId = identifier.parse(target.deploymentId);
@@ -71,11 +82,17 @@ export class ArtifactRequestAdmission {
71
82
  const timeoutMs = limits.timeout_seconds * 1000;
72
83
  const requestId = randomUUID();
73
84
  const prefix = `artifact-admission:${this.namespace}:{${this.namespace}:${projectId}}`;
74
- const keys = [`${prefix}:app`, `${prefix}:deployment:${deploymentId}`];
85
+ const keyed = [[`${prefix}:app`, maximum], [`${prefix}:deployment:${deploymentId}`, maximum]];
86
+ if (ingress) {
87
+ const id = artifactNextIngressIdSchema.parse(ingress.id), subject = subjectHashSchema.parse(ingress.subjectHash).slice(0, 16);
88
+ const client = createHash('sha256').update(z.string().min(1).max(64).parse(ingress.clientBucket)).digest('hex').slice(0, 16);
89
+ keyed.push([`${prefix}:ingress:${id}`, ingressConcurrencyShare(maximum)], [`${prefix}:ingress:${id}:subject:${subject}`, INGRESS_CONCURRENCY.perSubject], [`${prefix}:ingress:${id}:client:${client}`, INGRESS_CONCURRENCY.perClient]);
90
+ }
91
+ const keys = keyed.map(([key]) => key);
75
92
  const began = performance.now();
76
93
  let result;
77
94
  try {
78
- result = z.tuple([z.number().int().min(0).max(1), z.number().int().positive()]).parse(await this.options.redis.eval(ACQUIRE, 2, ...keys, requestId, maximum, timeoutMs));
95
+ result = z.tuple([z.number().int().min(0).max(1), z.number().int().positive()]).parse(await this.options.redis.eval(ACQUIRE, keys.length, ...keys, requestId, ...keyed.map(([, max]) => max), timeoutMs));
79
96
  }
80
97
  catch {
81
98
  throw new ArtifactAdmissionError(503, 'admission_unavailable');
@@ -93,7 +110,7 @@ export class ArtifactRequestAdmission {
93
110
  cleanup?.();
94
111
  // Repeated finish/close/error notifications must never free another
95
112
  // request's permit, nor cause a failed command to be retried.
96
- releasePromise ??= this.options.redis.eval(RELEASE, 2, ...keys, requestId).then(() => undefined, () => { throw new ArtifactAdmissionError(503, 'admission_unavailable'); });
113
+ releasePromise ??= this.options.redis.eval(RELEASE, keys.length, ...keys, requestId).then(() => undefined, () => { throw new ArtifactAdmissionError(503, 'admission_unavailable'); });
97
114
  return releasePromise;
98
115
  };
99
116
  const releaseQuietly = () => {
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import type { TunnelLease } from '../tunnel/index.js';
3
3
  import type { GatewaySession } from './session.js';
4
4
  import type { GatewayUsageEvent } from './usage.js';
5
+ import { type IngressAuditBatch, type IngressExchangeRequest, type IngressExchangeResponse, type IngressGrantRequest, type IngressGrantResponse, type IngressRevokeRequest, type IngressSnapshot } from '../server/ingress-control-contract.js';
5
6
  export declare const gatewayTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
6
7
  kind: z.ZodLiteral<"development">;
7
8
  sessionId: z.ZodString;
@@ -36,6 +37,10 @@ export declare const gatewayTargetSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
36
37
  frameworkBuildId: z.ZodString;
37
38
  upstreamUrl: z.ZodString;
38
39
  upstreamAudience: z.ZodOptional<z.ZodString>;
40
+ runtimeTransport: z.ZodOptional<z.ZodEnum<{
41
+ "cloud-run": "cloud-run";
42
+ "local-docker": "local-docker";
43
+ }>>;
39
44
  active: z.ZodBoolean;
40
45
  published: z.ZodOptional<z.ZodBoolean>;
41
46
  projectId: z.ZodString;
@@ -70,6 +75,10 @@ declare const latticeInvokeGrantSchema: z.ZodObject<{
70
75
  frameworkBuildId: z.ZodString;
71
76
  upstreamUrl: z.ZodString;
72
77
  upstreamAudience: z.ZodOptional<z.ZodString>;
78
+ runtimeTransport: z.ZodOptional<z.ZodEnum<{
79
+ "cloud-run": "cloud-run";
80
+ "local-docker": "local-docker";
81
+ }>>;
73
82
  publicOrigin: z.ZodString;
74
83
  environment: z.ZodString;
75
84
  principalSub: z.ZodString;
@@ -86,6 +95,26 @@ export declare class GatewayControlError extends Error {
86
95
  readonly status: number;
87
96
  constructor(status: number);
88
97
  }
98
+ /** Codes an ingress control route may answer with (design §(i)), and the status each one carries. */
99
+ declare const INGRESS_CONTROL_CODES: {
100
+ readonly invalid_token: 401;
101
+ readonly exchange_denied: 403;
102
+ readonly not_found: 404;
103
+ readonly rate_limited: 429;
104
+ readonly temporarily_unavailable: 503;
105
+ };
106
+ export type IngressControlCode = keyof typeof INGRESS_CONTROL_CODES;
107
+ /**
108
+ * A refusal an ingress control route decided: its contract code and Atlas's `Retry-After`, so the
109
+ * gateway answers the caller with Atlas's decision instead of inventing one. `code` is null when the
110
+ * response named no contract code matching its status (a misconfigured control token, a bare 500):
111
+ * the gateway then answers 503, never a code that would send the client into a sign-in loop.
112
+ */
113
+ export declare class IngressControlError extends GatewayControlError {
114
+ readonly code: IngressControlCode | null;
115
+ readonly retryAfterSeconds?: number | undefined;
116
+ constructor(status: number, code: IngressControlCode | null, retryAfterSeconds?: number | undefined);
117
+ }
89
118
  export declare class ArtifactGatewayControl {
90
119
  private readonly options;
91
120
  private readonly origin;
@@ -95,6 +124,17 @@ export declare class ArtifactGatewayControl {
95
124
  fetch?: typeof fetch;
96
125
  });
97
126
  private call;
127
+ private send;
128
+ /** Every enabled ingress host (GET, at most 1 MiB); the gateway pulls it on a timer, never per request. */
129
+ ingressSnapshot(): Promise<IngressSnapshot>;
130
+ /** Verifies an identity provider's token for an ingress; refusals carry Atlas's contract code. */
131
+ ingressExchange(body: IngressExchangeRequest): Promise<IngressExchangeResponse>;
132
+ /** Re-checks one ingress request against the active deployment and returns where to proxy it. */
133
+ ingressGrant(body: IngressGrantRequest): Promise<IngressGrantResponse>;
134
+ /** Logout: revokes every identity provider token the subject was issued up to Atlas's now. */
135
+ ingressRevoke(body: IngressRevokeRequest): Promise<void>;
136
+ /** One batch of refusals the gateway decided locally (Atlas audits the ones it decided itself). */
137
+ ingressAudit(body: IngressAuditBatch): Promise<void>;
98
138
  /** One idempotent batch; Atlas reports events it refused (for example, a retired target). */
99
139
  usage(events: GatewayUsageEvent[]): Promise<{
100
140
  rejected: number;
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { normalizePublicOrigin } from '../tunnel/protocol.js';
4
4
  import { artifactRequestContextSchema } from '../server/request-context.js';
5
5
  import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
6
+ import { INGRESS_SNAPSHOT_MAX_BYTES, ingressExchangeResponseSchema, ingressGrantResponseSchema, ingressSnapshotSchema, } from '../server/ingress-control-contract.js';
6
7
  const id = z.string().min(1).max(255);
7
8
  const base = {
8
9
  projectId: id, ownerId: id, environment: id, publicOrigin: z.string().transform(normalizePublicOrigin),
@@ -17,7 +18,7 @@ const base = {
17
18
  };
18
19
  export const gatewayTargetSchema = z.discriminatedUnion('kind', [
19
20
  z.object({ ...base, kind: z.literal('development'), sessionId: id, epoch: z.number().int().positive() }),
20
- z.object({ ...base, kind: z.literal('hosted'), deploymentId: id, frameworkBuildId: artifactNextFrameworkBuildIdSchema, upstreamUrl: z.string().url(), upstreamAudience: z.string().url().optional(), active: z.boolean(), published: z.boolean().optional() }),
21
+ z.object({ ...base, kind: z.literal('hosted'), deploymentId: id, frameworkBuildId: artifactNextFrameworkBuildIdSchema, upstreamUrl: z.string().url(), upstreamAudience: z.string().url().optional(), runtimeTransport: z.enum(['cloud-run', 'local-docker']).optional(), active: z.boolean(), published: z.boolean().optional() }),
21
22
  ]);
22
23
  const bootstrapSchema = z.object({ parentOrigin: z.string().transform(normalizePublicOrigin), channelId: id, nonce: z.string().min(16).max(255) });
23
24
  const usageResultSchema = z.object({ rejected: z.number().int().nonnegative() });
@@ -27,6 +28,7 @@ const latticeInvokeGrantSchema = z.object({
27
28
  frameworkBuildId: artifactNextFrameworkBuildIdSchema,
28
29
  upstreamUrl: z.string().url(),
29
30
  upstreamAudience: z.string().url().optional(),
31
+ runtimeTransport: z.enum(['cloud-run', 'local-docker']).optional(),
30
32
  publicOrigin: z.string().min(1).max(2048),
31
33
  environment: id,
32
34
  principalSub: id,
@@ -46,6 +48,49 @@ export class GatewayControlError extends Error {
46
48
  this.name = 'GatewayControlError';
47
49
  }
48
50
  }
51
+ /** Codes an ingress control route may answer with (design §(i)), and the status each one carries. */
52
+ const INGRESS_CONTROL_CODES = { invalid_token: 401, exchange_denied: 403, not_found: 404, rate_limited: 429, temporarily_unavailable: 503 };
53
+ const INGRESS_ERROR_BODY_MAX_BYTES = 4096;
54
+ function isIngressControlCode(value) {
55
+ return Object.hasOwn(INGRESS_CONTROL_CODES, value);
56
+ }
57
+ /**
58
+ * A refusal an ingress control route decided: its contract code and Atlas's `Retry-After`, so the
59
+ * gateway answers the caller with Atlas's decision instead of inventing one. `code` is null when the
60
+ * response named no contract code matching its status (a misconfigured control token, a bare 500):
61
+ * the gateway then answers 503, never a code that would send the client into a sign-in loop.
62
+ */
63
+ export class IngressControlError extends GatewayControlError {
64
+ code;
65
+ retryAfterSeconds;
66
+ constructor(status, code, retryAfterSeconds) {
67
+ super(status);
68
+ this.code = code;
69
+ this.retryAfterSeconds = retryAfterSeconds;
70
+ this.name = 'IngressControlError';
71
+ }
72
+ }
73
+ /** The contract code of a refused control response, read from at most 4 KiB of its JSON body. */
74
+ async function ingressRefusal(response) {
75
+ const retryAfter = Number(response.headers.get('retry-after'));
76
+ const retryAfterSeconds = Number.isSafeInteger(retryAfter) && retryAfter > 0 && retryAfter <= 3600 ? retryAfter : undefined;
77
+ let code = null;
78
+ try {
79
+ const length = Number(response.headers.get('content-length'));
80
+ const text = length > INGRESS_ERROR_BODY_MAX_BYTES ? '' : (await response.text()).slice(0, INGRESS_ERROR_BODY_MAX_BYTES);
81
+ const parsed = z.object({ error: z.string() }).safeParse(JSON.parse(text));
82
+ const named = parsed.success && isIngressControlCode(parsed.data.error) ? parsed.data.error : null;
83
+ // A code is believed only with the status the contract gives it.
84
+ code = named !== null && INGRESS_CONTROL_CODES[named] === response.status ? named : null;
85
+ }
86
+ catch {
87
+ code = null;
88
+ }
89
+ finally {
90
+ await response.body?.cancel().catch(() => undefined);
91
+ }
92
+ return new IngressControlError(response.status, code, retryAfterSeconds);
93
+ }
49
94
  export class ArtifactGatewayControl {
50
95
  options;
51
96
  origin;
@@ -55,15 +100,19 @@ export class ArtifactGatewayControl {
55
100
  if (options.token.length < 32)
56
101
  throw new Error('Artifact control plane token requires at least 32 characters');
57
102
  }
58
- async call(path, body, timeoutMs = 10_000) {
103
+ call(path, body, timeoutMs = 10_000) {
104
+ return this.send({ path, body, timeoutMs, maxBytes: 128 * 1024 });
105
+ }
106
+ async send({ path, body, timeoutMs, maxBytes, ingress = false }) {
59
107
  const response = await (this.options.fetch ?? fetch)(`${this.origin}/api/artifact-studio/next/control/${path}`, {
60
- method: 'POST', headers: { authorization: `Bearer ${this.options.token}`, 'content-type': 'application/json' },
61
- body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
108
+ method: body === undefined ? 'GET' : 'POST',
109
+ headers: { authorization: `Bearer ${this.options.token}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
110
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
62
111
  });
63
112
  if (!response.ok)
64
- throw new GatewayControlError(response.status);
113
+ throw ingress ? await ingressRefusal(response) : new GatewayControlError(response.status);
65
114
  const contentLength = Number(response.headers.get('content-length'));
66
- if (contentLength > 128 * 1024) {
115
+ if (contentLength > maxBytes) {
67
116
  await response.body?.cancel();
68
117
  throw new GatewayControlError(502);
69
118
  }
@@ -78,7 +127,7 @@ export class ArtifactGatewayControl {
78
127
  if (done)
79
128
  break;
80
129
  length += value.length;
81
- if (length > 128 * 1024)
130
+ if (length > maxBytes)
82
131
  throw new GatewayControlError(502);
83
132
  chunks.push(value);
84
133
  }
@@ -88,6 +137,26 @@ export class ArtifactGatewayControl {
88
137
  await reader.cancel().catch(() => undefined);
89
138
  }
90
139
  }
140
+ /** Every enabled ingress host (GET, at most 1 MiB); the gateway pulls it on a timer, never per request. */
141
+ async ingressSnapshot() {
142
+ return ingressSnapshotSchema.parse(await this.send({ path: 'ingress-snapshot', timeoutMs: 10_000, maxBytes: INGRESS_SNAPSHOT_MAX_BYTES }));
143
+ }
144
+ /** Verifies an identity provider's token for an ingress; refusals carry Atlas's contract code. */
145
+ async ingressExchange(body) {
146
+ return ingressExchangeResponseSchema.parse(await this.send({ path: 'ingress-exchange', body, timeoutMs: 10_000, maxBytes: 16 * 1024, ingress: true }));
147
+ }
148
+ /** Re-checks one ingress request against the active deployment and returns where to proxy it. */
149
+ async ingressGrant(body) {
150
+ return ingressGrantResponseSchema.parse(await this.send({ path: 'ingress-grant', body, timeoutMs: 10_000, maxBytes: 16 * 1024, ingress: true }));
151
+ }
152
+ /** Logout: revokes every identity provider token the subject was issued up to Atlas's now. */
153
+ async ingressRevoke(body) {
154
+ await this.send({ path: 'ingress-revoke', body, timeoutMs: 10_000, maxBytes: 16 * 1024, ingress: true });
155
+ }
156
+ /** One batch of refusals the gateway decided locally (Atlas audits the ones it decided itself). */
157
+ async ingressAudit(body) {
158
+ await this.send({ path: 'ingress-audit', body, timeoutMs: 5_000, maxBytes: 16 * 1024, ingress: true });
159
+ }
91
160
  /** One idempotent batch; Atlas reports events it refused (for example, a retired target). */
92
161
  async usage(events) {
93
162
  const value = await this.call('usage', { events }, 5_000);