@sequenceholdings/artifact-studio 0.6.1 → 0.6.3

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 (35) hide show
  1. package/README.md +43 -0
  2. package/VERSION-PIN.md +7 -0
  3. package/dist/manifest-next-ingress.d.ts +213 -0
  4. package/dist/manifest-next-ingress.js +187 -0
  5. package/dist/manifest-next-reach.d.ts +40 -0
  6. package/dist/manifest-next-reach.js +48 -0
  7. package/dist/manifest-next.d.ts +133 -8
  8. package/dist/manifest-next.js +7 -46
  9. package/dist/manifest-static.d.ts +6 -0
  10. package/dist/manifest-static.js +10 -0
  11. package/dist/manifest.d.ts +67 -0
  12. package/dist/manifest.js +7 -0
  13. package/dist/server/api-client.d.ts +1 -0
  14. package/dist/server/api-client.js +2 -1
  15. package/dist/server/broker-contract.d.ts +33 -6
  16. package/dist/server/broker-contract.js +20 -2
  17. package/dist/server/client.js +31 -16
  18. package/dist/server/external-context.js +1 -1
  19. package/dist/server/ingress-control-contract.d.ts +272 -0
  20. package/dist/server/ingress-control-contract.js +196 -0
  21. package/dist/server/ingress-routes.d.ts +76 -0
  22. package/dist/server/ingress-routes.js +150 -0
  23. package/dist/server/internal/verify-any.d.ts +3 -1
  24. package/dist/server/internal/verify-any.js +13 -3
  25. package/dist/server/processes.d.ts +56 -0
  26. package/dist/server/processes.js +13 -0
  27. package/dist/server/request-context.d.ts +8 -4
  28. package/dist/server/request-context.js +8 -6
  29. package/dist/server/runtime-config.d.ts +1 -1
  30. package/dist/templates/nextjs/README.md +9 -0
  31. package/dist/tunnel/protocol.js +2 -4
  32. package/dist/tunnel/reserved-headers.d.ts +7 -0
  33. package/dist/tunnel/reserved-headers.js +20 -0
  34. package/package.json +9 -1
  35. package/templates/nextjs/README.md +9 -0
package/README.md CHANGED
@@ -79,6 +79,49 @@ because a reset can come after Atlas acted on them, and reject with fetch's
79
79
  resend under the same event id.) A response with any status is Atlas's answer
80
80
  and is never resent.
81
81
 
82
+ ## Calling agents from a Next.js server
83
+
84
+ An application that declares `/api/agents/` in `server.capabilities.api.read`
85
+ and `write` can reach a fixed set of agents API routes as the signed-in user:
86
+
87
+ | Area | Routes |
88
+ | --- | --- |
89
+ | Chat | `POST chat`, `POST chat/abort`, `GET chat/resume` |
90
+ | Agents | `GET agents`, `GET agents/:id` |
91
+ | Threads | `GET threads`, `POST threads`, `GET threads/:id`, `GET threads/:id/messages`, `PATCH threads/:id/messages/:messageId` |
92
+ | Jobs | `POST jobs`, `GET jobs/:id`, `DELETE jobs/:id` |
93
+ | Files | `POST files`, `GET files/:id`, `DELETE files/:id`, `GET files/:id/url` |
94
+
95
+ Atlas answers them with the agents API's own handlers, so agent `can_invoke`,
96
+ thread, job and file access stay enforced there. Any other agents path is
97
+ refused, as are service-mode calls and platform service accounts.
98
+
99
+ A file upload can't travel through the broker's JSON body. Store the file in a
100
+ Workspaces V2 workspace the application declares, then post a reference to it;
101
+ Atlas reads it as the user (up to 25 MB) and uploads it to the agents file
102
+ service:
103
+
104
+ ```ts
105
+ const uploaded = await client.api.post('/api/agents/files?agentId=' + agentId, {
106
+ storage: { workspaceId, nodeId },
107
+ })
108
+ ```
109
+
110
+ Use `client.api.stream` for a chat turn. Atlas has 60 seconds to start the
111
+ response, as for every broker call, but a stream may then run for as long as
112
+ the turn does; other calls must finish inside the 60 seconds. Calls from the
113
+ browser are buffered by the host, so stream a chat through an application server
114
+ route.
115
+
116
+ ```ts
117
+ const body = await client.api.stream('/api/agents/chat', {
118
+ method: 'POST',
119
+ body: { agentId, text },
120
+ signal: request.signal,
121
+ })
122
+ return new Response(body, { headers: { 'content-type': 'text/event-stream' } })
123
+ ```
124
+
82
125
  ## Grouping calls into a trace
83
126
 
84
127
  The Artifact host supplies tracing context automatically for supported platform
package/VERSION-PIN.md CHANGED
@@ -36,6 +36,13 @@ and an external principal reaches platform services only through `asService()`.
36
36
  0.6.0 also carries `error.atlas` (`{ code, detail }`) on a refused broker call
37
37
  (#13398); a step-up 401 still surfaces as `insufficient_user_authentication`.
38
38
 
39
+ 0.6.0 also accepts the `server.ingress` manifest block. Earlier CLIs and Atlas
40
+ 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
42
+ external context binds the canonical route path (`server/ingress-routes`): a
43
+ handler's `path` is canonicalised the same way before it is compared, and a
44
+ signed path that is not canonical is refused.
45
+
39
46
  Context version policy: a context verifier rejects any unknown top-level or
40
47
  `principal` key unless it sits under `ext` or is named in `crit` (and `crit`
41
48
  names only extensions the verifier understands; today none, so it is empty).
@@ -0,0 +1,213 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * `server.ingress`: opt-in front doors for callers that are not Atlas users (design §(a)). Each
4
+ * external ingress names an identity provider from the environment's catalog, the service account
5
+ * its callers act as, the real routes they call, and capabilities that must sit inside the
6
+ * server's own. Nothing environment-specific (issuer, audience, origins) belongs here.
7
+ */
8
+ export declare const ARTIFACT_NEXT_MAX_INGRESSES = 4;
9
+ export declare const ARTIFACT_NEXT_MAX_INGRESS_ROUTES = 64;
10
+ export declare const ARTIFACT_NEXT_INGRESS_DEFAULT_MAX_BODY_BYTES: number;
11
+ export declare const ARTIFACT_NEXT_INGRESS_MAX_BODY_BYTES: number;
12
+ /** The content types an ingress route may return. No HTML, JavaScript or SVG, ever. */
13
+ export declare const ARTIFACT_NEXT_INGRESS_RESPONSE_TYPES: readonly ["application/json", "application/problem+json", "application/pdf", "application/zip", "image/png", "image/jpeg", "image/webp", "text/csv", "application/octet-stream"];
14
+ /** Verified principal fields the broker may bind into an evidence operation's variables. */
15
+ export declare const ARTIFACT_NEXT_INGRESS_EVIDENCE_SOURCES: readonly ["subject", "email", "authTime", "amr", "client.ip", "client.userAgent", "issuer"];
16
+ declare const STORAGE_ROLE_RANK: {
17
+ readonly viewer: 0;
18
+ readonly editor: 1;
19
+ readonly admin: 2;
20
+ };
21
+ export declare const artifactNextIngressIdSchema: z.ZodString;
22
+ export declare const artifactNextIngressProviderRefSchema: z.ZodString;
23
+ export declare const artifactNextIngressRouteSchema: z.ZodObject<{
24
+ path: z.ZodString;
25
+ methods: z.ZodArray<z.ZodEnum<{
26
+ GET: "GET";
27
+ POST: "POST";
28
+ PUT: "PUT";
29
+ PATCH: "PATCH";
30
+ DELETE: "DELETE";
31
+ }>>;
32
+ fresh_auth_seconds: z.ZodOptional<z.ZodNumber>;
33
+ max_body_bytes: z.ZodDefault<z.ZodNumber>;
34
+ responses: z.ZodDefault<z.ZodArray<z.ZodEnum<{
35
+ "application/json": "application/json";
36
+ "application/problem+json": "application/problem+json";
37
+ "application/pdf": "application/pdf";
38
+ "application/zip": "application/zip";
39
+ "image/png": "image/png";
40
+ "image/jpeg": "image/jpeg";
41
+ "image/webp": "image/webp";
42
+ "text/csv": "text/csv";
43
+ "application/octet-stream": "application/octet-stream";
44
+ }>>>;
45
+ }, z.core.$strict>;
46
+ export type ArtifactNextIngressRoute = z.infer<typeof artifactNextIngressRouteSchema>;
47
+ export declare const artifactNextExternalIngressSchema: z.ZodObject<{
48
+ id: z.ZodString;
49
+ provider: z.ZodString;
50
+ service_account: z.ZodString;
51
+ headers: z.ZodDefault<z.ZodArray<z.ZodString>>;
52
+ capabilities: z.ZodPrefault<z.ZodObject<{
53
+ storage: z.ZodOptional<z.ZodRecord<z.ZodUUID, z.ZodEnum<{
54
+ viewer: "viewer";
55
+ editor: "editor";
56
+ admin: "admin";
57
+ }>>>;
58
+ data: z.ZodPrefault<z.ZodObject<{
59
+ read: z.ZodDefault<z.ZodArray<z.ZodString>>;
60
+ write: z.ZodDefault<z.ZodArray<z.ZodString>>;
61
+ }, z.core.$strict>>;
62
+ api: z.ZodPrefault<z.ZodObject<{
63
+ read: z.ZodDefault<z.ZodArray<z.ZodString>>;
64
+ write: z.ZodDefault<z.ZodArray<z.ZodString>>;
65
+ }, z.core.$strict>>;
66
+ functions: z.ZodPrefault<z.ZodObject<{
67
+ invoke: z.ZodDefault<z.ZodArray<z.ZodString>>;
68
+ }, z.core.$strict>>;
69
+ audit: z.ZodOptional<z.ZodObject<{
70
+ service: z.ZodString;
71
+ }, z.core.$strict>>;
72
+ processes: z.ZodOptional<z.ZodNever>;
73
+ }, z.core.$strict>>;
74
+ evidence: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
75
+ bind: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEnum<{
76
+ email: "email";
77
+ subject: "subject";
78
+ authTime: "authTime";
79
+ amr: "amr";
80
+ "client.ip": "client.ip";
81
+ "client.userAgent": "client.userAgent";
82
+ issuer: "issuer";
83
+ }>>>;
84
+ fresh_auth_seconds: z.ZodOptional<z.ZodNumber>;
85
+ }, z.core.$strict>>>;
86
+ routes: z.ZodArray<z.ZodObject<{
87
+ path: z.ZodString;
88
+ methods: z.ZodArray<z.ZodEnum<{
89
+ GET: "GET";
90
+ POST: "POST";
91
+ PUT: "PUT";
92
+ PATCH: "PATCH";
93
+ DELETE: "DELETE";
94
+ }>>;
95
+ fresh_auth_seconds: z.ZodOptional<z.ZodNumber>;
96
+ max_body_bytes: z.ZodDefault<z.ZodNumber>;
97
+ responses: z.ZodDefault<z.ZodArray<z.ZodEnum<{
98
+ "application/json": "application/json";
99
+ "application/problem+json": "application/problem+json";
100
+ "application/pdf": "application/pdf";
101
+ "application/zip": "application/zip";
102
+ "image/png": "image/png";
103
+ "image/jpeg": "image/jpeg";
104
+ "image/webp": "image/webp";
105
+ "text/csv": "text/csv";
106
+ "application/octet-stream": "application/octet-stream";
107
+ }>>>;
108
+ }, z.core.$strict>>;
109
+ }, z.core.$strict>;
110
+ export type ArtifactNextExternalIngress = z.infer<typeof artifactNextExternalIngressSchema>;
111
+ export declare const artifactNextIngressSchema: z.ZodObject<{
112
+ external: z.ZodArray<z.ZodObject<{
113
+ id: z.ZodString;
114
+ provider: z.ZodString;
115
+ service_account: z.ZodString;
116
+ headers: z.ZodDefault<z.ZodArray<z.ZodString>>;
117
+ capabilities: z.ZodPrefault<z.ZodObject<{
118
+ storage: z.ZodOptional<z.ZodRecord<z.ZodUUID, z.ZodEnum<{
119
+ viewer: "viewer";
120
+ editor: "editor";
121
+ admin: "admin";
122
+ }>>>;
123
+ data: z.ZodPrefault<z.ZodObject<{
124
+ read: z.ZodDefault<z.ZodArray<z.ZodString>>;
125
+ write: z.ZodDefault<z.ZodArray<z.ZodString>>;
126
+ }, z.core.$strict>>;
127
+ api: z.ZodPrefault<z.ZodObject<{
128
+ read: z.ZodDefault<z.ZodArray<z.ZodString>>;
129
+ write: z.ZodDefault<z.ZodArray<z.ZodString>>;
130
+ }, z.core.$strict>>;
131
+ functions: z.ZodPrefault<z.ZodObject<{
132
+ invoke: z.ZodDefault<z.ZodArray<z.ZodString>>;
133
+ }, z.core.$strict>>;
134
+ audit: z.ZodOptional<z.ZodObject<{
135
+ service: z.ZodString;
136
+ }, z.core.$strict>>;
137
+ processes: z.ZodOptional<z.ZodNever>;
138
+ }, z.core.$strict>>;
139
+ evidence: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
140
+ bind: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEnum<{
141
+ email: "email";
142
+ subject: "subject";
143
+ authTime: "authTime";
144
+ amr: "amr";
145
+ "client.ip": "client.ip";
146
+ "client.userAgent": "client.userAgent";
147
+ issuer: "issuer";
148
+ }>>>;
149
+ fresh_auth_seconds: z.ZodOptional<z.ZodNumber>;
150
+ }, z.core.$strict>>>;
151
+ routes: z.ZodArray<z.ZodObject<{
152
+ path: z.ZodString;
153
+ methods: z.ZodArray<z.ZodEnum<{
154
+ GET: "GET";
155
+ POST: "POST";
156
+ PUT: "PUT";
157
+ PATCH: "PATCH";
158
+ DELETE: "DELETE";
159
+ }>>;
160
+ fresh_auth_seconds: z.ZodOptional<z.ZodNumber>;
161
+ max_body_bytes: z.ZodDefault<z.ZodNumber>;
162
+ responses: z.ZodDefault<z.ZodArray<z.ZodEnum<{
163
+ "application/json": "application/json";
164
+ "application/problem+json": "application/problem+json";
165
+ "application/pdf": "application/pdf";
166
+ "application/zip": "application/zip";
167
+ "image/png": "image/png";
168
+ "image/jpeg": "image/jpeg";
169
+ "image/webp": "image/webp";
170
+ "text/csv": "text/csv";
171
+ "application/octet-stream": "application/octet-stream";
172
+ }>>>;
173
+ }, z.core.$strict>>;
174
+ }, z.core.$strict>>;
175
+ }, z.core.$strict>;
176
+ export type ArtifactNextIngress = z.infer<typeof artifactNextIngressSchema>;
177
+ /** The server fields an ingress is checked against, as the server schema parses them. */
178
+ interface ServerForIngress {
179
+ capabilities: {
180
+ data: {
181
+ read: readonly string[];
182
+ write: readonly string[];
183
+ };
184
+ api: {
185
+ read: readonly string[];
186
+ write: readonly string[];
187
+ };
188
+ functions: {
189
+ invoke: readonly string[];
190
+ };
191
+ storage?: Readonly<Record<string, keyof typeof STORAGE_ROLE_RANK>>;
192
+ audit?: {
193
+ service: string;
194
+ };
195
+ };
196
+ service_account?: string;
197
+ resources: {
198
+ concurrency: number;
199
+ max_instances: number;
200
+ };
201
+ ingress?: ArtifactNextIngress;
202
+ }
203
+ /**
204
+ * Cross-field rules between `server.ingress` and the rest of `server`: one ingress per provider,
205
+ * a service account distinct from the app's, capabilities inside the server's own (verbatim list
206
+ * membership; for storage, the same workspace at a role no higher than the server's), and room for
207
+ * the ingress share of concurrency. Called from the server schema's refinement.
208
+ */
209
+ export declare function validateIngressAgainstServer({ server, ctx }: {
210
+ server: ServerForIngress;
211
+ ctx: z.RefinementCtx;
212
+ }): void;
213
+ export {};
@@ -0,0 +1,187 @@
1
+ import { storageScopesSchema } from '@sequenceholdings/storage';
2
+ import { z } from 'zod';
3
+ import { isPrefixApiCapabilityEntry } from './manifest-static.js';
4
+ import { artifactNextAuditCapabilitySchema, boundedRef, dataReadRef, ormOperationRef, reachShape, serviceAccountRef, } from './manifest-next-reach.js';
5
+ import { INGRESS_ROUTE_METHODS, ingressRouteTemplatesOverlap, parseRouteTemplate, } from './server/ingress-routes.js';
6
+ import { INGRESS_CLIENT_ADDRESS_HEADERS, isPrivateRequestHeader } from './tunnel/reserved-headers.js';
7
+ /**
8
+ * `server.ingress`: opt-in front doors for callers that are not Atlas users (design §(a)). Each
9
+ * external ingress names an identity provider from the environment's catalog, the service account
10
+ * its callers act as, the real routes they call, and capabilities that must sit inside the
11
+ * server's own. Nothing environment-specific (issuer, audience, origins) belongs here.
12
+ */
13
+ export const ARTIFACT_NEXT_MAX_INGRESSES = 4;
14
+ export const ARTIFACT_NEXT_MAX_INGRESS_ROUTES = 64;
15
+ export const ARTIFACT_NEXT_INGRESS_DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
16
+ export const ARTIFACT_NEXT_INGRESS_MAX_BODY_BYTES = 32 * 1024 * 1024;
17
+ /** The content types an ingress route may return. No HTML, JavaScript or SVG, ever. */
18
+ export const ARTIFACT_NEXT_INGRESS_RESPONSE_TYPES = [
19
+ 'application/json', 'application/problem+json', 'application/pdf', 'application/zip',
20
+ 'image/png', 'image/jpeg', 'image/webp', 'text/csv', 'application/octet-stream',
21
+ ];
22
+ /** Verified principal fields the broker may bind into an evidence operation's variables. */
23
+ export const ARTIFACT_NEXT_INGRESS_EVIDENCE_SOURCES = [
24
+ 'subject', 'email', 'authTime', 'amr', 'client.ip', 'client.userAgent', 'issuer',
25
+ ];
26
+ const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
27
+ const STORAGE_ROLE_RANK = { viewer: 0, editor: 1, admin: 2 };
28
+ export const artifactNextIngressIdSchema = z.string().regex(/^[a-z][a-z0-9-]{0,31}$/, 'Ingress ids are lowercase, start with a letter, and are at most 32 characters');
29
+ export const artifactNextIngressProviderRefSchema = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/, 'Identity provider references are lowercase catalog keys');
30
+ /** Seconds since the caller's interactive sign-in a step requires; 0 means no requirement. */
31
+ const freshAuthSeconds = z.number().int().refine((value) => value === 0 || (value >= 60 && value <= 86_400), 'fresh_auth_seconds is 0 (none) or between 60 and 86400');
32
+ // Framework-internal and routing-override headers: declaring one would make it a CORS-allowed
33
+ // request header that can steer Next.js (middleware subrequests, data requests), override the
34
+ // method or URL, or forge the request id the platform generates.
35
+ const INGRESS_FRAMEWORK_HEADER_PREFIX = /^x-(?:middleware-|nextjs-|invoke-|vercel-|now-)/;
36
+ const INGRESS_ROUTING_OVERRIDE_HEADERS = new Set([
37
+ 'x-http-method-override', 'x-method-override', 'x-original-url', 'x-rewrite-url', 'x-original-method',
38
+ 'x-request-id', 'x-forwarded', 'x-real-ip',
39
+ ]);
40
+ const ingressHeaderName = z.string().regex(/^x-[a-z0-9-]{1,48}$/, 'Ingress headers are lowercase x- names of at most 50 characters').refine((name) => !isPrivateRequestHeader(name) && !INGRESS_CLIENT_ADDRESS_HEADERS.includes(name), 'Ingress headers cannot use a name the platform proxy strips or owns').refine((name) => !INGRESS_FRAMEWORK_HEADER_PREFIX.test(name) && !INGRESS_ROUTING_OVERRIDE_HEADERS.has(name), 'Ingress headers cannot use a framework-internal or routing-override name');
41
+ function unique(values) {
42
+ return new Set(values).size === values.length;
43
+ }
44
+ export const artifactNextIngressRouteSchema = z.strictObject({
45
+ path: z.string().refine((template) => parseRouteTemplate(template) !== null, 'Ingress routes are exact /api/ paths: literal segments or whole-segment {name} placeholders, no wildcards, %, queries or trailing slash'),
46
+ methods: z.array(z.enum(INGRESS_ROUTE_METHODS)).min(1).max(INGRESS_ROUTE_METHODS.length).refine(unique, 'Route methods must be unique'),
47
+ fresh_auth_seconds: freshAuthSeconds.optional(),
48
+ max_body_bytes: z.number().int().min(1).max(ARTIFACT_NEXT_INGRESS_MAX_BODY_BYTES).default(ARTIFACT_NEXT_INGRESS_DEFAULT_MAX_BODY_BYTES),
49
+ responses: z.array(z.enum(ARTIFACT_NEXT_INGRESS_RESPONSE_TYPES)).min(1).max(ARTIFACT_NEXT_INGRESS_RESPONSE_TYPES.length)
50
+ .refine(unique, 'Route response types must be unique').default(['application/json']),
51
+ }).superRefine((route, ctx) => {
52
+ if ((route.fresh_auth_seconds ?? 0) > 0 && route.methods.some((method) => !MUTATING_METHODS.has(method))) {
53
+ ctx.addIssue({ code: 'custom', path: ['fresh_auth_seconds'], message: 'fresh_auth_seconds applies only to mutating methods; declare the read twin as its own route' });
54
+ }
55
+ });
56
+ const evidenceEntrySchema = z.strictObject({
57
+ bind: z.record(z.string().regex(/^[_A-Za-z][_0-9A-Za-z]{0,63}$/, 'Evidence binds GraphQL variable names'), z.enum(ARTIFACT_NEXT_INGRESS_EVIDENCE_SOURCES))
58
+ .refine((bind) => Object.keys(bind).length <= 16, 'At most 16 bound variables per operation').default({}),
59
+ fresh_auth_seconds: freshAuthSeconds.optional(),
60
+ }).refine((entry) => Object.keys(entry.bind).length > 0 || (entry.fresh_auth_seconds ?? 0) > 0, 'An evidence entry binds at least one variable or requires fresh authentication');
61
+ // Unlike the app-wide grammar, an ingress write names each ORM operation: a table ref would let an
62
+ // external caller run any mutation the ORM role allows over that table, and evidence binding and
63
+ // freshness are keyed per named operation (D28; design C-3, "by name, no globs").
64
+ const ingressDataWriteRef = boundedRef.refine((ref) => ormOperationRef.test(ref), 'Ingress ORM writes name each operation: namespace/ops/Operation');
65
+ const ingressCapabilitiesSchema = z.strictObject({
66
+ storage: storageScopesSchema.optional(),
67
+ data: z.strictObject({
68
+ read: z.array(dataReadRef).max(256).default([]),
69
+ write: z.array(ingressDataWriteRef).max(256).default([]),
70
+ }).prefault({}),
71
+ api: reachShape.api,
72
+ functions: reachShape.functions,
73
+ audit: artifactNextAuditCapabilitySchema.optional(),
74
+ processes: z.never({ error: 'Ingress capabilities cannot start Lattice processes' }).optional(),
75
+ }).prefault({});
76
+ /** Whole-segment `*` only, and never a form `apiCapabilityPathAllowed` would treat as a prefix. */
77
+ function ingressApiEntryProblem(entry) {
78
+ if (entry.endsWith('/') || isPrefixApiCapabilityEntry(entry)) {
79
+ return 'Ingress API capabilities cannot end in / or /*: a prefix would admit every future path below it';
80
+ }
81
+ if (entry.split('/').some((part) => part.includes('*') && part !== '*'))
82
+ return 'Ingress API capabilities use whole-segment * only';
83
+ return null;
84
+ }
85
+ export const artifactNextExternalIngressSchema = z.strictObject({
86
+ id: artifactNextIngressIdSchema,
87
+ provider: artifactNextIngressProviderRefSchema,
88
+ service_account: serviceAccountRef,
89
+ headers: z.array(ingressHeaderName).max(8).refine(unique, 'Ingress headers must be unique').default([]),
90
+ capabilities: ingressCapabilitiesSchema,
91
+ evidence: z.record(z.string().regex(ormOperationRef, 'Evidence keys are ORM operation references (namespace/ops/Operation)'), evidenceEntrySchema)
92
+ .refine((evidence) => Object.keys(evidence).length <= 256, 'At most 256 evidence operations').default({}),
93
+ routes: z.array(artifactNextIngressRouteSchema).min(1).max(ARTIFACT_NEXT_MAX_INGRESS_ROUTES),
94
+ }).superRefine((ingress, ctx) => {
95
+ const seen = new Map();
96
+ for (const [index, route] of ingress.routes.entries()) {
97
+ const parsed = parseRouteTemplate(route.path);
98
+ if (!parsed)
99
+ continue;
100
+ for (const method of route.methods) {
101
+ const declared = seen.get(method) ?? [];
102
+ if (declared.some((other) => other.template === parsed.template)) {
103
+ ctx.addIssue({ code: 'custom', path: ['routes', index, 'methods'], message: `${method} ${route.path} is declared twice` });
104
+ }
105
+ else if (declared.some((other) => ingressRouteTemplatesOverlap({ left: other, right: parsed }))) {
106
+ ctx.addIssue({ code: 'custom', path: ['routes', index, 'path'], message: `${method} ${route.path} overlaps another declared route; one request must match one route` });
107
+ }
108
+ seen.set(method, [...declared, parsed]);
109
+ }
110
+ }
111
+ for (const access of ['read', 'write']) {
112
+ for (const [index, entry] of ingress.capabilities.api[access].entries()) {
113
+ const problem = ingressApiEntryProblem(entry);
114
+ if (problem)
115
+ ctx.addIssue({ code: 'custom', path: ['capabilities', 'api', access, index], message: problem });
116
+ }
117
+ }
118
+ for (const operation of Object.keys(ingress.evidence)) {
119
+ if (!ingress.capabilities.data.write.includes(operation)) {
120
+ ctx.addIssue({ code: 'custom', path: ['evidence', operation], message: 'Evidence operations must be listed in the ingress capabilities.data.write' });
121
+ }
122
+ }
123
+ });
124
+ export const artifactNextIngressSchema = z.strictObject({
125
+ external: z.array(artifactNextExternalIngressSchema).min(1).max(ARTIFACT_NEXT_MAX_INGRESSES),
126
+ });
127
+ function requireMembers({ ctx, path, entries, allowed, label }) {
128
+ for (const [index, entry] of entries.entries()) {
129
+ if (!allowed.includes(entry))
130
+ ctx.addIssue({ code: 'custom', path: [...path, index], message: `${entry} must also be declared in server.capabilities.${label}` });
131
+ }
132
+ }
133
+ /**
134
+ * Cross-field rules between `server.ingress` and the rest of `server`: one ingress per provider,
135
+ * a service account distinct from the app's, capabilities inside the server's own (verbatim list
136
+ * membership; for storage, the same workspace at a role no higher than the server's), and room for
137
+ * the ingress share of concurrency. Called from the server schema's refinement.
138
+ */
139
+ export function validateIngressAgainstServer({ server, ctx }) {
140
+ const external = server.ingress?.external;
141
+ if (!external)
142
+ return;
143
+ if (server.resources.concurrency * server.resources.max_instances < 2) {
144
+ ctx.addIssue({ code: 'custom', path: ['resources'], message: 'An app that declares an ingress needs concurrency × max_instances ≥ 2, so the ingress share never takes the whole app' });
145
+ }
146
+ const ids = new Set();
147
+ const providers = new Set();
148
+ const serviceAccounts = new Set();
149
+ for (const [index, ingress] of external.entries()) {
150
+ const at = ['ingress', 'external', index];
151
+ if (ids.has(ingress.id))
152
+ ctx.addIssue({ code: 'custom', path: [...at, 'id'], message: `Ingress id ${ingress.id} is declared twice` });
153
+ ids.add(ingress.id);
154
+ if (providers.has(ingress.provider))
155
+ ctx.addIssue({ code: 'custom', path: [...at, 'provider'], message: `At most one ingress per identity provider; ${ingress.provider} is used twice` });
156
+ providers.add(ingress.provider);
157
+ // A shared account would carry the union of both ingresses' platform grants, leaving the
158
+ // per-ingress split to principal.ingress, which is a routing label and not an authorization attribute.
159
+ if (serviceAccounts.has(ingress.service_account)) {
160
+ ctx.addIssue({ code: 'custom', path: [...at, 'service_account'], message: 'Each ingress needs its own service account' });
161
+ }
162
+ serviceAccounts.add(ingress.service_account);
163
+ // These are reference strings, so a UUID here and a slug on the server for the same account pass.
164
+ // Atlas's deploy preflight resolves both to account ids and refuses an ingress account equal to
165
+ // the app's or to another ingress's (preflightArtifactAttachments).
166
+ if (server.service_account !== undefined && ingress.service_account === server.service_account) {
167
+ ctx.addIssue({ code: 'custom', path: [...at, 'service_account'], message: 'An ingress service account must differ from server.service_account' });
168
+ }
169
+ const own = ingress.capabilities;
170
+ const declared = server.capabilities;
171
+ const capabilities = [...at, 'capabilities'];
172
+ requireMembers({ ctx, path: [...capabilities, 'data', 'read'], entries: own.data.read, allowed: declared.data.read, label: 'data.read' });
173
+ requireMembers({ ctx, path: [...capabilities, 'data', 'write'], entries: own.data.write, allowed: declared.data.write, label: 'data.write' });
174
+ requireMembers({ ctx, path: [...capabilities, 'api', 'read'], entries: own.api.read, allowed: declared.api.read, label: 'api.read' });
175
+ requireMembers({ ctx, path: [...capabilities, 'api', 'write'], entries: own.api.write, allowed: declared.api.write, label: 'api.write' });
176
+ requireMembers({ ctx, path: [...capabilities, 'functions', 'invoke'], entries: own.functions.invoke, allowed: declared.functions.invoke, label: 'functions.invoke' });
177
+ for (const [workspace, role] of Object.entries(own.storage ?? {})) {
178
+ const ceiling = declared.storage?.[workspace];
179
+ if (ceiling === undefined || STORAGE_ROLE_RANK[role] > STORAGE_ROLE_RANK[ceiling]) {
180
+ ctx.addIssue({ code: 'custom', path: [...capabilities, 'storage', workspace], message: 'Ingress storage must name a server.capabilities.storage workspace at a role no higher than the server\'s' });
181
+ }
182
+ }
183
+ if (own.audit && own.audit.service !== declared.audit?.service) {
184
+ ctx.addIssue({ code: 'custom', path: [...capabilities, 'audit', 'service'], message: 'Ingress audit service must equal server.capabilities.audit.service' });
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ export declare const boundedRef: z.ZodString;
3
+ export declare const serviceAccountRef: z.ZodString;
4
+ export declare const apiPath: z.ZodString;
5
+ export declare const ormTableRef: RegExp;
6
+ export declare const ormOperationRef: RegExp;
7
+ export declare const dataReadRef: z.ZodString;
8
+ export declare const dataWriteRef: z.ZodString;
9
+ export declare const latticeProcessRef: z.ZodString;
10
+ export declare const reachShape: {
11
+ storage: z.ZodOptional<z.ZodRecord<z.ZodUUID, z.ZodEnum<{
12
+ viewer: "viewer";
13
+ editor: "editor";
14
+ admin: "admin";
15
+ }>>>;
16
+ data: z.ZodPrefault<z.ZodObject<{
17
+ read: z.ZodDefault<z.ZodArray<z.ZodString>>;
18
+ write: z.ZodDefault<z.ZodArray<z.ZodString>>;
19
+ }, z.core.$strict>>;
20
+ api: z.ZodPrefault<z.ZodObject<{
21
+ read: z.ZodDefault<z.ZodArray<z.ZodString>>;
22
+ write: z.ZodDefault<z.ZodArray<z.ZodString>>;
23
+ }, z.core.$strict>>;
24
+ functions: z.ZodPrefault<z.ZodObject<{
25
+ invoke: z.ZodDefault<z.ZodArray<z.ZodString>>;
26
+ }, z.core.$strict>>;
27
+ processes: z.ZodPrefault<z.ZodObject<{
28
+ start: z.ZodDefault<z.ZodArray<z.ZodString>>;
29
+ read: z.ZodDefault<z.ZodArray<z.ZodString>>;
30
+ act: z.ZodDefault<z.ZodArray<z.ZodString>>;
31
+ }, z.core.$strict>>;
32
+ };
33
+ /**
34
+ * Where the server's audit events (`client.audit.record`) go: an Atlas audit service id. Every
35
+ * application may record under `artifact_studio`; Atlas admits a first-party service, such as
36
+ * `application_portal`, only for the projects it registers for it. Server-only: browsers can't audit.
37
+ */
38
+ export declare const artifactNextAuditCapabilitySchema: z.ZodObject<{
39
+ service: z.ZodString;
40
+ }, z.core.$strict>;
@@ -0,0 +1,48 @@
1
+ import { storageScopesSchema } from '@sequenceholdings/storage';
2
+ import { z } from 'zod';
3
+ // Reach primitives shared by the app-wide capability blocks and the external ingress block.
4
+ export const boundedRef = z.string().min(1).max(255).regex(/^[a-zA-Z0-9][a-zA-Z0-9_./:-]*$/).refine((value) => value.split('/').every((part) => part !== '' && part !== '.' && part !== '..'), 'Resource references cannot contain empty segments or traversal');
5
+ export const serviceAccountRef = z.string().regex(/^([a-z][a-z0-9-]{1,98}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/);
6
+ export const apiPath = z.string().min(1).max(512).refine((value) => {
7
+ if (!value.startsWith('/api/') || value === '/api/' || value === '/api/*')
8
+ return false;
9
+ if (/[\\%?#\s\x00-\x1f]/.test(value))
10
+ return false;
11
+ return value.split('/').slice(1).every((part, index, parts) => part !== '.' && part !== '..' && (part !== '' || index === parts.length - 1));
12
+ }, 'API capability paths must be scoped below /api/ without encoded paths, traversal, queries, or fragments');
13
+ // ORM references are what a delegated data token covers (orm-data-plane checkDataTokenScope):
14
+ // a query needs namespace/table for every table it reads, and a mutation needs its
15
+ // namespace/ops/Operation (or table coverage). Any other shape can never match, so it is
16
+ // a typo to report here rather than a 403 at request time. The patterns mirror the ORM's
17
+ // NAMESPACE_PATTERN, IDENTIFIER_PATTERN and GraphQL operation names; they are kept local so
18
+ // browser code that parses manifests does not load the ORM package. platformUsers is the
19
+ // ORM's built-in platform-user entity (PLATFORM_USER_TABLE), a logical key outside the
20
+ // identifier pattern that queries traversing user relations read.
21
+ export const ormTableRef = /^[a-z][a-z0-9_]{0,40}\/(?:[a-z][a-z0-9_]{0,62}|platformUsers)$/;
22
+ export const ormOperationRef = /^[a-z][a-z0-9_]{0,40}\/ops\/[_A-Za-z][_0-9A-Za-z]*$/;
23
+ export const dataReadRef = boundedRef.refine((ref) => ormTableRef.test(ref), 'ORM read references use namespace/table');
24
+ export const dataWriteRef = boundedRef.refine((ref) => ormTableRef.test(ref) || ormOperationRef.test(ref), 'ORM write references use namespace/ops/Operation or namespace/table');
25
+ export const latticeProcessRef = z.string().min(2).max(128).regex(/^[A-Za-z0-9._-]+$/, 'Process references are Lattice process ids');
26
+ export const reachShape = {
27
+ storage: storageScopesSchema.optional(),
28
+ data: z.strictObject({ read: z.array(dataReadRef).max(256).default([]), write: z.array(dataWriteRef).max(256).default([]) }).prefault({}),
29
+ api: z.strictObject({ read: z.array(apiPath).max(256).default([]), write: z.array(apiPath).max(256).default([]) }).prefault({}),
30
+ functions: z.strictObject({ invoke: z.array(boundedRef).max(256).default([]) }).prefault({}),
31
+ // `start`: processes the server may start runs of. `read`: processes whose runs, template and
32
+ // the user's own steps it may read. `act`: processes whose human steps it may advance and
33
+ // reassign, and whose runs' viewers it may manage. All name exact process ids; the user's own
34
+ // Invoker / Viewer / Advancer / Owner grants decide what happens.
35
+ processes: z.strictObject({
36
+ start: z.array(latticeProcessRef).max(256).default([]),
37
+ read: z.array(latticeProcessRef).max(256).default([]),
38
+ act: z.array(latticeProcessRef).max(256).default([]),
39
+ }).prefault({}),
40
+ };
41
+ /**
42
+ * Where the server's audit events (`client.audit.record`) go: an Atlas audit service id. Every
43
+ * application may record under `artifact_studio`; Atlas admits a first-party service, such as
44
+ * `application_portal`, only for the projects it registers for it. Server-only: browsers can't audit.
45
+ */
46
+ export const artifactNextAuditCapabilitySchema = z.strictObject({
47
+ service: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/, 'Audit services are Atlas audit service ids, such as artifact_studio'),
48
+ });