@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.
- package/README.md +43 -0
- package/VERSION-PIN.md +7 -0
- package/dist/manifest-next-ingress.d.ts +213 -0
- package/dist/manifest-next-ingress.js +187 -0
- package/dist/manifest-next-reach.d.ts +40 -0
- package/dist/manifest-next-reach.js +48 -0
- package/dist/manifest-next.d.ts +133 -8
- package/dist/manifest-next.js +7 -46
- package/dist/manifest-static.d.ts +6 -0
- package/dist/manifest-static.js +10 -0
- package/dist/manifest.d.ts +67 -0
- package/dist/manifest.js +7 -0
- package/dist/server/api-client.d.ts +1 -0
- package/dist/server/api-client.js +2 -1
- package/dist/server/broker-contract.d.ts +33 -6
- package/dist/server/broker-contract.js +20 -2
- package/dist/server/client.js +31 -16
- package/dist/server/external-context.js +1 -1
- package/dist/server/ingress-control-contract.d.ts +272 -0
- package/dist/server/ingress-control-contract.js +196 -0
- package/dist/server/ingress-routes.d.ts +76 -0
- package/dist/server/ingress-routes.js +150 -0
- package/dist/server/internal/verify-any.d.ts +3 -1
- package/dist/server/internal/verify-any.js +13 -3
- package/dist/server/processes.d.ts +56 -0
- package/dist/server/processes.js +13 -0
- package/dist/server/request-context.d.ts +8 -4
- package/dist/server/request-context.js +8 -6
- package/dist/server/runtime-config.d.ts +1 -1
- package/dist/templates/nextjs/README.md +9 -0
- package/dist/tunnel/protocol.js +2 -4
- package/dist/tunnel/reserved-headers.d.ts +7 -0
- package/dist/tunnel/reserved-headers.js +20 -0
- package/package.json +9 -1
- 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
|
+
});
|