@sequenceholdings/artifact-studio 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/VERSION-PIN.md +9 -0
- package/dist/active-deploy-policy.d.ts +9 -3
- package/dist/active-deploy-policy.js +20 -8
- package/dist/artifact-env-bindings.d.ts +9 -0
- package/dist/artifact-env-bindings.js +32 -0
- package/dist/build.js +7 -0
- package/dist/cli.js +66 -13
- package/dist/deployment-validation.d.ts +2 -0
- package/dist/deployment-validation.js +2 -0
- package/dist/dev-link.js +29 -3
- package/dist/env-binding-scan.d.ts +10 -0
- package/dist/env-binding-scan.js +179 -0
- package/dist/env-bindings.d.ts +52 -0
- package/dist/env-bindings.js +180 -0
- package/dist/gateway/control.d.ts +33 -0
- package/dist/gateway/control.js +29 -0
- package/dist/gateway/hosted-proxy.d.ts +5 -0
- package/dist/gateway/hosted-proxy.js +4 -1
- package/dist/gateway/lattice-invoke.d.ts +48 -0
- package/dist/gateway/lattice-invoke.js +245 -0
- package/dist/gateway/lattice-upstream.d.ts +38 -0
- package/dist/gateway/lattice-upstream.js +136 -0
- package/dist/gateway/server.d.ts +3 -0
- package/dist/gateway/server.js +4 -5
- package/dist/gateway/session.d.ts +17 -2
- package/dist/gateway/session.js +27 -6
- package/dist/gateway-main.js +2 -0
- package/dist/loopback.d.ts +8 -0
- package/dist/loopback.js +13 -0
- package/dist/manifest-custom-roles.d.ts +11 -1
- package/dist/manifest-custom-roles.js +13 -5
- package/dist/manifest-next.d.ts +55 -0
- package/dist/manifest-next.js +35 -2
- package/dist/manifest-static.d.ts +35 -0
- package/dist/manifest-static.js +12 -0
- package/dist/manifest.d.ts +48 -0
- package/dist/next/protocol.js +2 -1
- package/dist/next-build.d.ts +2 -1
- package/dist/next-build.js +12 -7
- package/dist/next-dev.js +2 -1
- package/dist/next-project.js +2 -1
- package/dist/offline/cache.d.ts +4 -0
- package/dist/offline/cache.js +7 -0
- package/dist/offline/connector.d.ts +26 -0
- package/dist/offline/connector.js +118 -0
- package/dist/offline/crypto.d.ts +5 -0
- package/dist/offline/crypto.js +24 -0
- package/dist/offline/index.d.ts +8 -0
- package/dist/offline/index.js +7 -0
- package/dist/offline/indexed-db.d.ts +10 -0
- package/dist/offline/indexed-db.js +104 -0
- package/dist/offline/outbox.d.ts +7 -0
- package/dist/offline/outbox.js +46 -0
- package/dist/offline/replay.d.ts +25 -0
- package/dist/offline/replay.js +192 -0
- package/dist/offline/secure-store.d.ts +6 -0
- package/dist/offline/secure-store.js +51 -0
- package/dist/offline/types.d.ts +162 -0
- package/dist/offline/types.js +1 -0
- package/dist/orm-json-scalars.d.ts +21 -0
- package/dist/orm-json-scalars.js +21 -0
- package/dist/project.js +3 -3
- package/dist/role-definitions-notice.d.ts +7 -0
- package/dist/role-definitions-notice.js +19 -0
- package/dist/runtime-contracts.d.ts +7 -7
- package/dist/runtime-contracts.js +2 -1
- package/dist/sandbox-lint.js +1 -1
- package/dist/sdk-orm.d.ts +24 -0
- package/dist/sdk-orm.js +28 -0
- package/dist/sdk.d.ts +127 -0
- package/dist/sdk.js +1 -0
- package/dist/server/broker-contract.d.ts +191 -5
- package/dist/server/broker-contract.js +142 -1
- package/dist/server/client.d.ts +60 -12
- package/dist/server/client.js +143 -20
- package/dist/server/errors.d.ts +26 -0
- package/dist/server/errors.js +29 -0
- package/dist/server/managed-server-path.d.ts +6 -0
- package/dist/server/managed-server-path.js +14 -0
- package/dist/server/processes.d.ts +290 -0
- package/dist/server/processes.js +56 -0
- package/dist/server/request-context.d.ts +6 -0
- package/dist/server/request-context.js +12 -2
- package/dist/server/runtime-config.js +2 -1
- package/dist/server.d.ts +5 -1
- package/dist/server.js +1 -0
- package/dist/source-resolver.d.ts +9 -2
- package/dist/source-resolver.js +55 -8
- package/dist/templates/nextjs/AGENTS.md +10 -0
- package/dist/templates/nextjs/CLAUDE.md +1 -0
- package/dist/templates/nextjs/README.md +88 -0
- package/dist/templates/react-vite/AGENTS.md +8 -88
- package/dist/templates/react-vite/CLAUDE.md +0 -6
- package/dist/templates/react-vite/package.json +1 -1
- package/dist/templates/react-vite/src/App.tsx +37 -14
- package/dist/templates/react-vite/src/styles.css +10 -0
- package/dist/tunnel/client.js +10 -3
- package/dist/tunnel/protocol.js +2 -1
- package/package.json +7 -3
- package/templates/nextjs/AGENTS.md +10 -0
- package/templates/nextjs/CLAUDE.md +1 -0
- package/templates/nextjs/README.md +88 -0
- package/templates/react-vite/AGENTS.md +8 -88
- package/templates/react-vite/CLAUDE.md +0 -6
- package/templates/react-vite/package.json +1 -1
- package/templates/react-vite/src/App.tsx +37 -14
- package/templates/react-vite/src/styles.css +10 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { isManagedServerInvokePath } from '../server/managed-server-path.js';
|
|
5
|
+
import { ARTIFACT_CONTEXT_MAX_AGE_SECONDS, ARTIFACT_LATTICE_CONTEXT_MAX_AGE_SECONDS } from '../server/request-context.js';
|
|
6
|
+
import { normalizePublicOrigin } from '../tunnel/protocol.js';
|
|
7
|
+
import { ArtifactAdmissionError } from './admission.js';
|
|
8
|
+
import { GatewayControlError } from './control.js';
|
|
9
|
+
import { invokeHostedArtifact } from './lattice-upstream.js';
|
|
10
|
+
import { signGatewayContext } from './session.js';
|
|
11
|
+
const requestSchema = z.object({
|
|
12
|
+
server: z.string().min(1).max(255),
|
|
13
|
+
path: z.string().refine(isManagedServerInvokePath, 'Invalid managed server path'),
|
|
14
|
+
principalSub: z.string().min(1).max(255),
|
|
15
|
+
body: z.unknown().optional(),
|
|
16
|
+
lattice: z.object({
|
|
17
|
+
runId: z.string().min(1).max(255),
|
|
18
|
+
nodeId: z.string().min(1).max(255),
|
|
19
|
+
stepId: z.string().min(1).max(255),
|
|
20
|
+
processId: z.string().min(1).max(255),
|
|
21
|
+
}).strict(),
|
|
22
|
+
requestId: z.string().min(1).max(255),
|
|
23
|
+
timeoutMs: z.number().int().positive().max(300_000),
|
|
24
|
+
}).strict();
|
|
25
|
+
const BODY_CAP = Math.floor(1.1 * 1024 * 1024);
|
|
26
|
+
/** Lattice invoke, then the generic refusal for every other `/_sequence/` path. */
|
|
27
|
+
export async function routeSequenceRequest(input) {
|
|
28
|
+
if (await routeLatticeInvoke(input))
|
|
29
|
+
return true;
|
|
30
|
+
if (input.request.url?.startsWith('/_sequence/')) {
|
|
31
|
+
write(input.response, 404, { error: 'not_found' });
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
export async function routeLatticeInvoke(input) {
|
|
37
|
+
if ((input.request.url ?? '').split('?')[0] !== '/_sequence/lattice-invoke')
|
|
38
|
+
return false;
|
|
39
|
+
if (input.request.method !== 'POST') {
|
|
40
|
+
write(input.response, 404, { error: 'not_found' });
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
await handleLatticeInvoke(input);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
export async function handleLatticeInvoke(input) {
|
|
47
|
+
const startedAt = new Date().toISOString();
|
|
48
|
+
const startedMs = performance.now();
|
|
49
|
+
const presented = bearer(input.request);
|
|
50
|
+
if (!presented || !input.control.tokenMatches(presented)) {
|
|
51
|
+
write(input.response, 403, { error: 'lattice_invoke_denied' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = requestSchema.parse(JSON.parse((await readBody(input.request)).toString('utf8')));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
write(input.response, 400, { error: 'invalid_lattice_invoke' });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let grant;
|
|
63
|
+
try {
|
|
64
|
+
grant = await input.control.latticeInvoke({
|
|
65
|
+
server: parsed.server,
|
|
66
|
+
path: parsed.path,
|
|
67
|
+
principalSub: parsed.principalSub,
|
|
68
|
+
lattice: parsed.lattice,
|
|
69
|
+
requestId: parsed.requestId,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
const status = error instanceof GatewayControlError ? error.status : 502;
|
|
74
|
+
if (status === 503) {
|
|
75
|
+
write(input.response, 503, { error: 'lattice_invoke_unavailable' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (status === 401 || status === 403 || status === 404) {
|
|
79
|
+
write(input.response, 403, { error: 'lattice_invoke_denied' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
write(input.response, 502, { error: 'lattice_invoke_failed' });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (grant.principalSub !== parsed.principalSub || grant.path !== parsed.path) {
|
|
86
|
+
write(input.response, 403, { error: 'lattice_invoke_denied' });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const observed = await invokeAdmittedLattice({ ...input, grant, parsed });
|
|
90
|
+
input.usage?.record(latticeUsageEvent({ grant, requestId: parsed.requestId, startedAt, startedMs, ...observed }));
|
|
91
|
+
}
|
|
92
|
+
/** The browser proxy records on response finish. This path returns first, so it records the upstream result itself. */
|
|
93
|
+
async function invokeAdmittedLattice(input) {
|
|
94
|
+
const signal = callerAbortSignal(input);
|
|
95
|
+
let release = () => { };
|
|
96
|
+
try {
|
|
97
|
+
const admitted = await acquireLatticeAdmission({ admission: input.admission, grant: input.grant });
|
|
98
|
+
release = admitted.bind(input.response, () => input.response.destroy());
|
|
99
|
+
const session = syntheticSession(input.grant, input.parsed.lattice);
|
|
100
|
+
const requestContext = signGatewayContext({
|
|
101
|
+
session,
|
|
102
|
+
method: 'POST',
|
|
103
|
+
path: input.grant.path,
|
|
104
|
+
privateKey: input.privateKey,
|
|
105
|
+
keyId: input.keyId,
|
|
106
|
+
serviceAccountId: input.grant.serviceAccountId,
|
|
107
|
+
requestId: input.parsed.requestId,
|
|
108
|
+
ttlSeconds: Math.min(ARTIFACT_LATTICE_CONTEXT_MAX_AGE_SECONDS, Math.max(ARTIFACT_CONTEXT_MAX_AGE_SECONDS, Math.ceil(input.parsed.timeoutMs / 1000))),
|
|
109
|
+
});
|
|
110
|
+
const result = await (input.invoke ?? invokeHostedArtifact)({
|
|
111
|
+
upstreamUrl: input.grant.upstreamUrl,
|
|
112
|
+
...(input.grant.upstreamAudience ? { upstreamAudience: input.grant.upstreamAudience } : {}),
|
|
113
|
+
path: input.grant.path,
|
|
114
|
+
body: input.parsed.body ?? {},
|
|
115
|
+
requestContext,
|
|
116
|
+
timeoutMs: input.parsed.timeoutMs,
|
|
117
|
+
signal,
|
|
118
|
+
});
|
|
119
|
+
write(input.response, 200, { status: result.status, body: result.body });
|
|
120
|
+
return latticeOutcome({ aborted: signal.aborted, status: result.status });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error instanceof ArtifactAdmissionError) {
|
|
124
|
+
writeAdmissionFailure(input.response, error);
|
|
125
|
+
return { outcome: 'error', httpStatus: error.status };
|
|
126
|
+
}
|
|
127
|
+
if (signal.aborted || input.response.writableEnded)
|
|
128
|
+
return { outcome: 'cancelled', httpStatus: 499 };
|
|
129
|
+
write(input.response, 502, { error: 'lattice_invoke_failed' });
|
|
130
|
+
return { outcome: 'error', httpStatus: 502 };
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
release();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function latticeOutcome({ aborted, status }) {
|
|
137
|
+
const httpStatus = status >= 100 && status <= 599 ? status : 502;
|
|
138
|
+
if (aborted)
|
|
139
|
+
return { outcome: 'cancelled', httpStatus };
|
|
140
|
+
return { outcome: httpStatus >= 400 ? 'error' : 'success', httpStatus };
|
|
141
|
+
}
|
|
142
|
+
function latticeUsageEvent(input) {
|
|
143
|
+
return {
|
|
144
|
+
// One id per gateway call. Retries of the same Lattice step reuse requestId,
|
|
145
|
+
// and a later attempt with a different outcome would be rejected as a
|
|
146
|
+
// conflicting duplicate. Delivery retries resend this same object.
|
|
147
|
+
eventId: `gateway:${randomUUID()}`,
|
|
148
|
+
requestId: input.requestId,
|
|
149
|
+
actorId: input.grant.principalSub,
|
|
150
|
+
projectId: input.grant.projectId,
|
|
151
|
+
deploymentId: input.grant.deploymentId,
|
|
152
|
+
environment: input.grant.environment,
|
|
153
|
+
startedAt: input.startedAt,
|
|
154
|
+
endedAt: new Date().toISOString(),
|
|
155
|
+
measurements: { requestCount: 1, durationMs: Math.max(0, Math.round(performance.now() - input.startedMs)) },
|
|
156
|
+
outcome: input.outcome,
|
|
157
|
+
httpStatus: input.httpStatus,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function writeAdmissionFailure(response, error) {
|
|
161
|
+
const capacity = error.status === 429;
|
|
162
|
+
write(response, capacity ? 429 : 503, {
|
|
163
|
+
error: capacity ? 'lattice_invoke_capacity' : 'lattice_invoke_unavailable',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/** Same per-app lease the browser proxy acquires. Missing admission fails closed. */
|
|
167
|
+
async function acquireLatticeAdmission(input) {
|
|
168
|
+
if (!input.admission)
|
|
169
|
+
throw new ArtifactAdmissionError(503, 'admission_unavailable');
|
|
170
|
+
return input.admission.acquire({
|
|
171
|
+
environment: input.grant.environment,
|
|
172
|
+
projectId: input.grant.projectId,
|
|
173
|
+
deploymentId: input.grant.deploymentId,
|
|
174
|
+
resources: input.grant.resources,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/** The worker closing its connection must abort the Cloud Run POST still in flight. */
|
|
178
|
+
function callerAbortSignal(input) {
|
|
179
|
+
const abort = new AbortController();
|
|
180
|
+
const stop = () => {
|
|
181
|
+
if (!input.response.writableEnded)
|
|
182
|
+
abort.abort();
|
|
183
|
+
};
|
|
184
|
+
if (typeof input.request.on === 'function')
|
|
185
|
+
input.request.on('aborted', stop);
|
|
186
|
+
if (typeof input.response.on === 'function')
|
|
187
|
+
input.response.on('close', stop);
|
|
188
|
+
return abort.signal;
|
|
189
|
+
}
|
|
190
|
+
function syntheticSession(grant, lattice) {
|
|
191
|
+
const publicOrigin = normalizePublicOrigin(grant.publicOrigin);
|
|
192
|
+
return {
|
|
193
|
+
version: 1,
|
|
194
|
+
kind: 'hosted',
|
|
195
|
+
sessionId: null,
|
|
196
|
+
deploymentId: grant.deploymentId,
|
|
197
|
+
frameworkBuildId: grant.frameworkBuildId,
|
|
198
|
+
projectId: grant.projectId,
|
|
199
|
+
ownerId: grant.principalSub,
|
|
200
|
+
environment: grant.environment,
|
|
201
|
+
publicOrigin,
|
|
202
|
+
expiresAt: Date.now() + 60_000,
|
|
203
|
+
published: true,
|
|
204
|
+
bridge: {
|
|
205
|
+
parentOrigin: publicOrigin,
|
|
206
|
+
channelId: 'lattice-invoke',
|
|
207
|
+
nonce: randomBytes(16).toString('hex'),
|
|
208
|
+
},
|
|
209
|
+
lattice,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function bearer(request) {
|
|
213
|
+
const header = request.headers.authorization;
|
|
214
|
+
if (typeof header !== 'string' || !header.startsWith('Bearer '))
|
|
215
|
+
return null;
|
|
216
|
+
return header.slice('Bearer '.length);
|
|
217
|
+
}
|
|
218
|
+
function readBody(request) {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
const chunks = [];
|
|
221
|
+
let length = 0;
|
|
222
|
+
request.on('data', (chunk) => {
|
|
223
|
+
length += chunk.byteLength;
|
|
224
|
+
if (length > BODY_CAP) {
|
|
225
|
+
reject(new Error('body too large'));
|
|
226
|
+
request.destroy();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
chunks.push(chunk);
|
|
230
|
+
});
|
|
231
|
+
request.on('end', () => resolve(Buffer.concat(chunks)));
|
|
232
|
+
request.on('error', reject);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
function write(response, status, body) {
|
|
236
|
+
if (response.headersSent)
|
|
237
|
+
return;
|
|
238
|
+
response.writeHead(status, {
|
|
239
|
+
...(status >= 400 ? { connection: 'close' } : {}),
|
|
240
|
+
'content-type': 'application/json',
|
|
241
|
+
'cache-control': 'no-store',
|
|
242
|
+
'x-content-type-options': 'nosniff',
|
|
243
|
+
});
|
|
244
|
+
response.end(JSON.stringify(body));
|
|
245
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { request as httpsRequest } from 'node:https';
|
|
2
|
+
export interface HostedArtifactInvocation {
|
|
3
|
+
upstreamUrl: string;
|
|
4
|
+
upstreamAudience?: string;
|
|
5
|
+
path: string;
|
|
6
|
+
body: unknown;
|
|
7
|
+
requestContext: string;
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
/** Wall-clock cancellation from the caller, combined with `timeoutMs`. */
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
identityTokenProvider?: (input: {
|
|
12
|
+
audience: string;
|
|
13
|
+
signal: AbortSignal;
|
|
14
|
+
}) => Promise<string>;
|
|
15
|
+
/** Tests supply the socket. Production uses `https.request`. */
|
|
16
|
+
dial?: typeof httpsRequest;
|
|
17
|
+
/** Tests supply the hop. Production dials the validated Cloud Run origin. */
|
|
18
|
+
dispatch?: (input: {
|
|
19
|
+
origin: URL;
|
|
20
|
+
path: string;
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
body: Buffer;
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
signal: AbortSignal;
|
|
25
|
+
}) => Promise<{
|
|
26
|
+
status: number;
|
|
27
|
+
contentType?: string;
|
|
28
|
+
body: Buffer;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Buffered POST into a hosted Next.js app. The destination must already be a
|
|
33
|
+
* Cloud Run origin Atlas resolved; this rejects anything else before dialing.
|
|
34
|
+
*/
|
|
35
|
+
export declare function invokeHostedArtifact(input: HostedArtifactInvocation): Promise<{
|
|
36
|
+
status: number;
|
|
37
|
+
body: unknown;
|
|
38
|
+
}>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { request as httpsRequest } from 'node:https';
|
|
2
|
+
import { assertSafeRequest } from '../tunnel/protocol.js';
|
|
3
|
+
import { isManagedServerInvokePath } from '../server/managed-server-path.js';
|
|
4
|
+
import { HOSTED_ARTIFACT_PROXY_LIMITS, cloudRunOrigin, hostedIdentityToken } from './hosted-proxy.js';
|
|
5
|
+
const BODY_CAP = 1024 * 1024;
|
|
6
|
+
/**
|
|
7
|
+
* Buffered POST into a hosted Next.js app. The destination must already be a
|
|
8
|
+
* Cloud Run origin Atlas resolved; this rejects anything else before dialing.
|
|
9
|
+
*/
|
|
10
|
+
export async function invokeHostedArtifact(input) {
|
|
11
|
+
if (!isManagedServerInvokePath(input.path))
|
|
12
|
+
throw new Error('Invalid managed server path');
|
|
13
|
+
const upstream = cloudRunOrigin(input.upstreamUrl);
|
|
14
|
+
const audience = input.upstreamAudience === undefined ? upstream : cloudRunOrigin(input.upstreamAudience);
|
|
15
|
+
assertSafeRequest('POST', input.path);
|
|
16
|
+
const payload = Buffer.from(JSON.stringify(input.body ?? {}));
|
|
17
|
+
if (payload.byteLength > BODY_CAP)
|
|
18
|
+
throw new Error('Managed server body exceeds 1 MiB');
|
|
19
|
+
const timeoutMs = Math.min(Math.max(input.timeoutMs, 1), HOSTED_ARTIFACT_PROXY_LIMITS.maxTimeoutMs);
|
|
20
|
+
const signal = invocationSignal({ timeoutMs, signal: input.signal });
|
|
21
|
+
const token = await withSignal((input.identityTokenProvider ?? hostedIdentityToken)({ audience: audience.origin, signal }), signal);
|
|
22
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(token) || token.length > 16384)
|
|
23
|
+
throw new Error('Invalid platform token');
|
|
24
|
+
const headers = {
|
|
25
|
+
host: upstream.host,
|
|
26
|
+
'content-type': 'application/json',
|
|
27
|
+
'content-length': String(payload.byteLength),
|
|
28
|
+
'accept-encoding': 'identity',
|
|
29
|
+
'x-serverless-authorization': `Bearer ${token}`,
|
|
30
|
+
'x-sequence-artifact-context': input.requestContext,
|
|
31
|
+
};
|
|
32
|
+
const hop = { origin: upstream, path: input.path, headers, body: payload, timeoutMs, signal };
|
|
33
|
+
const response = await withSignal(input.dispatch ? input.dispatch(hop) : postCloudRun({ ...hop, dial: input.dial }), signal);
|
|
34
|
+
if (response.body.byteLength > BODY_CAP)
|
|
35
|
+
throw new Error('Managed server response exceeds 1 MiB');
|
|
36
|
+
return { status: response.status, body: decodeBody(response) };
|
|
37
|
+
}
|
|
38
|
+
function decodeBody(response) {
|
|
39
|
+
if (response.body.byteLength === 0)
|
|
40
|
+
return null;
|
|
41
|
+
const text = response.body.toString('utf8');
|
|
42
|
+
if (response.contentType?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json')
|
|
43
|
+
return text;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(text);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return text;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* `timeoutMs` is a deadline from the start of the call, not socket idle time.
|
|
53
|
+
* Streaming a chunk forever must not extend it. `signal` is the worker's
|
|
54
|
+
* disconnect; either one destroys the Cloud Run request.
|
|
55
|
+
*/
|
|
56
|
+
function invocationSignal({ timeoutMs, signal }) {
|
|
57
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
58
|
+
return signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
59
|
+
}
|
|
60
|
+
function invocationAbortError(signal) {
|
|
61
|
+
const reason = signal.reason;
|
|
62
|
+
if (reason instanceof Error && reason.name === 'TimeoutError')
|
|
63
|
+
return new Error('Managed server invocation timed out');
|
|
64
|
+
if (reason instanceof Error && /timed out/i.test(reason.message))
|
|
65
|
+
return reason;
|
|
66
|
+
return new Error('Managed server invocation cancelled');
|
|
67
|
+
}
|
|
68
|
+
function withSignal(work, signal) {
|
|
69
|
+
if (signal.aborted)
|
|
70
|
+
return Promise.reject(invocationAbortError(signal));
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const onAbort = () => reject(invocationAbortError(signal));
|
|
73
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
74
|
+
work.then((value) => { signal.removeEventListener('abort', onAbort); resolve(value); }, (error) => { signal.removeEventListener('abort', onAbort); reject(error); });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function postCloudRun(input) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
if (input.signal.aborted) {
|
|
80
|
+
reject(invocationAbortError(input.signal));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let settled = false;
|
|
84
|
+
let incoming;
|
|
85
|
+
let onAbort = () => { };
|
|
86
|
+
const stop = () => input.signal.removeEventListener('abort', onAbort);
|
|
87
|
+
const fail = (error) => {
|
|
88
|
+
if (settled)
|
|
89
|
+
return;
|
|
90
|
+
settled = true;
|
|
91
|
+
stop();
|
|
92
|
+
incoming?.destroy();
|
|
93
|
+
request.destroy();
|
|
94
|
+
reject(error instanceof Error ? error : new Error('Managed server invocation failed'));
|
|
95
|
+
};
|
|
96
|
+
const succeed = (value) => {
|
|
97
|
+
if (settled)
|
|
98
|
+
return;
|
|
99
|
+
settled = true;
|
|
100
|
+
stop();
|
|
101
|
+
resolve(value);
|
|
102
|
+
};
|
|
103
|
+
const request = (input.dial ?? httpsRequest)(input.origin, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
path: input.path,
|
|
106
|
+
headers: input.headers,
|
|
107
|
+
timeout: input.timeoutMs,
|
|
108
|
+
}, (response) => {
|
|
109
|
+
incoming = response;
|
|
110
|
+
const chunks = [];
|
|
111
|
+
let length = 0;
|
|
112
|
+
response.on('data', (chunk) => {
|
|
113
|
+
length += chunk.byteLength;
|
|
114
|
+
if (length > BODY_CAP) {
|
|
115
|
+
fail(new Error('Managed server response exceeds 1 MiB'));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
chunks.push(chunk);
|
|
119
|
+
});
|
|
120
|
+
response.on('end', () => {
|
|
121
|
+
const contentType = response.headers['content-type'];
|
|
122
|
+
succeed({
|
|
123
|
+
status: response.statusCode ?? 502,
|
|
124
|
+
...(typeof contentType === 'string' ? { contentType } : {}),
|
|
125
|
+
body: Buffer.concat(chunks),
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
response.on('error', fail);
|
|
129
|
+
});
|
|
130
|
+
onAbort = () => fail(invocationAbortError(input.signal));
|
|
131
|
+
input.signal.addEventListener('abort', onAbort, { once: true });
|
|
132
|
+
request.on('error', fail);
|
|
133
|
+
request.on('timeout', () => fail(new Error('Managed server invocation timed out')));
|
|
134
|
+
request.end(input.body);
|
|
135
|
+
});
|
|
136
|
+
}
|
package/dist/gateway/server.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
2
|
import { type TunnelLease, type TunnelBrokerOptions } from '../tunnel/index.js';
|
|
3
3
|
import { ArtifactGatewayControl } from './control.js';
|
|
4
|
+
import { type GatewaySessionSameSite } from './session.js';
|
|
4
5
|
import { type ArtifactRequestAdmission } from './admission.js';
|
|
5
6
|
import { type GatewayControlAdmission } from './control-admission.js';
|
|
6
7
|
export interface ArtifactGatewayOptions {
|
|
@@ -9,6 +10,8 @@ export interface ArtifactGatewayOptions {
|
|
|
9
10
|
parentOrigin: string;
|
|
10
11
|
gatewayOrigin: string;
|
|
11
12
|
sessionKey: string;
|
|
13
|
+
/** Defaults to `Strict`. `None` only for a local development gateway (see GatewaySessionSameSite). */
|
|
14
|
+
sessionCookieSameSite?: GatewaySessionSameSite;
|
|
12
15
|
contextPrivateKey: string;
|
|
13
16
|
contextKeyId: string;
|
|
14
17
|
/** Unique per process, never a shared Cloud Run revision name. */
|
package/dist/gateway/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { assertSafeRequest, normalizePublicOrigin } from '../tunnel/protocol.js'
|
|
|
6
6
|
import { GatewayControlError } from './control.js';
|
|
7
7
|
import { decodeGatewaySession, gatewaySessionCookies, renewGatewaySession, signGatewayContext } from './session.js';
|
|
8
8
|
import { proxyHostedArtifact } from './hosted-proxy.js';
|
|
9
|
+
import { routeSequenceRequest } from './lattice-invoke.js';
|
|
9
10
|
import { admitHostedMutation, ReleaseAdmissionError } from './release-admission.js';
|
|
10
11
|
import { ArtifactAdmissionError } from './admission.js';
|
|
11
12
|
import { gatewayClientAddress } from './control-admission.js';
|
|
@@ -136,7 +137,7 @@ export function createArtifactGateway(options) {
|
|
|
136
137
|
}
|
|
137
138
|
if (session.publicOrigin !== origin || session.bridge.parentOrigin !== parentOrigin)
|
|
138
139
|
throw new GatewayControlError(403);
|
|
139
|
-
response.setHeader('set-cookie', gatewaySessionCookies({ session, key: options.sessionKey, cookie: header(request, 'cookie') }));
|
|
140
|
+
response.setHeader('set-cookie', gatewaySessionCookies({ session, key: options.sessionKey, cookie: header(request, 'cookie'), sameSite: options.sessionCookieSameSite }));
|
|
140
141
|
json(response, 200, 'connected');
|
|
141
142
|
}
|
|
142
143
|
/** Tunnel credential validation, shared by connection admission and every lease check. */
|
|
@@ -285,10 +286,8 @@ export function createArtifactGateway(options) {
|
|
|
285
286
|
await exchangeTicket({ request, response, origin });
|
|
286
287
|
return;
|
|
287
288
|
}
|
|
288
|
-
if (request.
|
|
289
|
-
json(response, 404, 'not_found');
|
|
289
|
+
if (await routeSequenceRequest({ request, response, control: options.control, privateKey: options.contextPrivateKey, keyId: options.contextKeyId, admission: options.admission, usage }))
|
|
290
290
|
return;
|
|
291
|
-
}
|
|
292
291
|
if (['GET', 'HEAD'].includes(request.method ?? 'GET')) {
|
|
293
292
|
const url = new URL(request.url ?? '/', origin);
|
|
294
293
|
if (url.searchParams.has('__sequence_release')) {
|
|
@@ -319,7 +318,7 @@ export function createArtifactGateway(options) {
|
|
|
319
318
|
}
|
|
320
319
|
const renewed = renewGatewaySession({ session, authorizedExpiresAt: target.expiresAt });
|
|
321
320
|
if (renewed)
|
|
322
|
-
response.setHeader('set-cookie', gatewaySessionCookies({ session: renewed, key: options.sessionKey, cookie: header(request, 'cookie'), renewal: true }));
|
|
321
|
+
response.setHeader('set-cookie', gatewaySessionCookies({ session: renewed, key: options.sessionKey, cookie: header(request, 'cookie'), renewal: true, sameSite: options.sessionCookieSameSite }));
|
|
323
322
|
// Authorization precedes every response, including Next static assets and cached pages.
|
|
324
323
|
response.setHeader('content-security-policy', `frame-ancestors ${session.bridge.parentOrigin}`);
|
|
325
324
|
response.setHeader('referrer-policy', 'same-origin');
|
|
@@ -37,12 +37,25 @@ export declare function renewGatewaySession({ session, authorizedExpiresAt, now
|
|
|
37
37
|
authorizedExpiresAt: number;
|
|
38
38
|
now?: number;
|
|
39
39
|
}): GatewaySession | null;
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* `Strict` everywhere Atlas and the runtime domain are one site, as every deployed environment is.
|
|
42
|
+
* `None` is for the local stack only: Atlas on http://localhost is cross-site to
|
|
43
|
+
* https://*.apps.localhost, so browsers drop a `Strict` cookie inside the preview iframe.
|
|
44
|
+
* Partitioned keeps the relaxed cookie to Atlas's top-level site.
|
|
45
|
+
*/
|
|
46
|
+
export type GatewaySessionSameSite = 'Strict' | 'None';
|
|
47
|
+
/** Reads ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE; only a development gateway may relax the cookie. */
|
|
48
|
+
export declare function gatewaySessionSameSite({ value, appEnv }: {
|
|
49
|
+
value: string | undefined;
|
|
50
|
+
appEnv: string;
|
|
51
|
+
}): GatewaySessionSameSite;
|
|
52
|
+
export declare function gatewaySessionCookies({ session, key, cookie, renewal, now, sameSite }: {
|
|
41
53
|
session: GatewaySession;
|
|
42
54
|
key: string;
|
|
43
55
|
cookie?: string;
|
|
44
56
|
renewal?: boolean;
|
|
45
57
|
now?: number;
|
|
58
|
+
sameSite?: GatewaySessionSameSite;
|
|
46
59
|
}): string[];
|
|
47
60
|
export declare function encodeGatewaySession({ session, key }: {
|
|
48
61
|
session: GatewaySession;
|
|
@@ -58,7 +71,7 @@ export declare function decodeGatewaySession(input: {
|
|
|
58
71
|
frameworkBuildId?: string;
|
|
59
72
|
}): GatewaySession | null;
|
|
60
73
|
/** A fresh jti on every signature: a context names one originating request and its method. */
|
|
61
|
-
export declare function signGatewayContext({ session, method, path, privateKey, keyId, now, capabilities, serviceAccountId, requestId }: {
|
|
74
|
+
export declare function signGatewayContext({ session, method, path, privateKey, keyId, now, capabilities, serviceAccountId, requestId, ttlSeconds }: {
|
|
62
75
|
session: GatewaySession;
|
|
63
76
|
method: string;
|
|
64
77
|
path: string;
|
|
@@ -68,5 +81,7 @@ export declare function signGatewayContext({ session, method, path, privateKey,
|
|
|
68
81
|
capabilities?: string[];
|
|
69
82
|
serviceAccountId?: string | null;
|
|
70
83
|
requestId?: string;
|
|
84
|
+
/** Seconds until exp. Browser requests stay at 60; a Lattice run may last up to 300. */
|
|
85
|
+
ttlSeconds?: number;
|
|
71
86
|
}): string;
|
|
72
87
|
export {};
|
package/dist/gateway/session.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHmac, createPrivateKey, randomUUID, sign, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { artifactRequestContextSchema } from '../server/request-context.js';
|
|
3
|
+
import { ARTIFACT_CONTEXT_MAX_AGE_SECONDS, ARTIFACT_LATTICE_CONTEXT_MAX_AGE_SECONDS, artifactRequestContextSchema } from '../server/request-context.js';
|
|
4
4
|
import { normalizePublicOrigin } from '../tunnel/protocol.js';
|
|
5
5
|
import { artifactNextFrameworkBuildIdSchema } from '../runtime-contracts.js';
|
|
6
6
|
export const GATEWAY_SESSION_COOKIE = '__Host-atlas-artifact-session';
|
|
@@ -39,9 +39,23 @@ export function renewGatewaySession({ session, authorizedExpiresAt, now = Date.n
|
|
|
39
39
|
const expiresAt = Math.min(authorizedExpiresAt, session.maxExpiresAt ?? session.expiresAt);
|
|
40
40
|
return expiresAt > session.expiresAt ? { ...session, expiresAt } : null;
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
/** Reads ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE; only a development gateway may relax the cookie. */
|
|
43
|
+
export function gatewaySessionSameSite({ value, appEnv }) {
|
|
44
|
+
if (value === undefined || value === '' || value === 'strict')
|
|
45
|
+
return 'Strict';
|
|
46
|
+
if (value !== 'none')
|
|
47
|
+
throw new Error('ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE must be "strict" or "none"');
|
|
48
|
+
if (appEnv !== 'development')
|
|
49
|
+
throw new Error('ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE=none is only for a local development gateway');
|
|
50
|
+
return 'None';
|
|
51
|
+
}
|
|
52
|
+
function cookieAttributes(sameSite) {
|
|
53
|
+
return `Path=/; HttpOnly; Secure; ${sameSite === 'None' ? 'SameSite=None; Partitioned' : 'SameSite=Strict'}`;
|
|
54
|
+
}
|
|
55
|
+
export function gatewaySessionCookies({ session, key, cookie, renewal = false, now = Date.now(), sameSite = 'Strict' }) {
|
|
43
56
|
const maxAge = Math.max(0, Math.floor((session.expiresAt - now) / 1000));
|
|
44
|
-
const
|
|
57
|
+
const attributes = cookieAttributes(sameSite);
|
|
58
|
+
const value = `${encodeGatewaySession({ session, key })}; ${attributes}; Max-Age=${maxAge}`;
|
|
45
59
|
const current = renewal ? decodeGatewaySession({ cookie, key, origin: session.publicOrigin, now }) : session;
|
|
46
60
|
if (!current || !sameSessionScope(current, session))
|
|
47
61
|
return [];
|
|
@@ -63,7 +77,7 @@ export function gatewaySessionCookies({ session, key, cookie, renewal = false, n
|
|
|
63
77
|
revoked.push(name);
|
|
64
78
|
}
|
|
65
79
|
for (const name of [...revoked, ...sameScope.slice(0, Math.max(0, sameScope.length - 5))]) {
|
|
66
|
-
cookies.push(`${name}=;
|
|
80
|
+
cookies.push(`${name}=; ${attributes}; Max-Age=0`);
|
|
67
81
|
}
|
|
68
82
|
}
|
|
69
83
|
return cookies;
|
|
@@ -123,13 +137,20 @@ export function decodeGatewaySession(input) {
|
|
|
123
137
|
return matches.sort((left, right) => right.expiresAt - left.expiresAt || (left.deploymentId ?? '').localeCompare(right.deploymentId ?? ''))[0] ?? null;
|
|
124
138
|
}
|
|
125
139
|
/** A fresh jti on every signature: a context names one originating request and its method. */
|
|
126
|
-
export function signGatewayContext({ session, method, path, privateKey, keyId, now = Math.floor(Date.now() / 1000), capabilities = [], serviceAccountId = null, requestId = randomUUID() }) {
|
|
140
|
+
export function signGatewayContext({ session, method, path, privateKey, keyId, now = Math.floor(Date.now() / 1000), capabilities = [], serviceAccountId = null, requestId = randomUUID(), ttlSeconds = ARTIFACT_CONTEXT_MAX_AGE_SECONDS }) {
|
|
141
|
+
const latticeRun = session.lattice?.runId ?? null;
|
|
142
|
+
const ceiling = latticeRun !== null && latticeRun !== ''
|
|
143
|
+
? ARTIFACT_LATTICE_CONTEXT_MAX_AGE_SECONDS
|
|
144
|
+
: ARTIFACT_CONTEXT_MAX_AGE_SECONDS;
|
|
145
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > ceiling) {
|
|
146
|
+
throw new Error(`Artifact context lifetime must be 1..${ceiling} seconds`);
|
|
147
|
+
}
|
|
127
148
|
const context = artifactRequestContextSchema.parse({
|
|
128
149
|
v: 1, iss: 'atlas-artifact-gateway', aud: `artifact:${session.projectId}`,
|
|
129
150
|
sub: session.ownerId, projectId: session.projectId, environment: session.environment,
|
|
130
151
|
mode: session.kind === 'development' ? 'development' : session.published ? 'published' : 'preview',
|
|
131
152
|
deploymentId: session.deploymentId, devSessionId: session.sessionId, requestId, jti: randomUUID(),
|
|
132
|
-
method, path, iat: now, exp: now +
|
|
153
|
+
method, path, iat: now, exp: now + ttlSeconds, capabilities, serviceAccountId,
|
|
133
154
|
bridge: session.bridge, theme: 'system',
|
|
134
155
|
lattice: session.lattice ?? { runId: null, nodeId: null, stepId: null, processId: null },
|
|
135
156
|
...(session.taskRoutes ? { taskRoutes: session.taskRoutes } : {}),
|
package/dist/gateway-main.js
CHANGED
|
@@ -3,6 +3,7 @@ import { isIP } from 'node:net';
|
|
|
3
3
|
import { Redis } from 'ioredis';
|
|
4
4
|
import { ArtifactGatewayControl } from './gateway/control.js';
|
|
5
5
|
import { createArtifactGateway } from './gateway/server.js';
|
|
6
|
+
import { gatewaySessionSameSite } from './gateway/session.js';
|
|
6
7
|
import { RedisTunnelOwnership, RedisTunnelRelay } from './tunnel/redis.js';
|
|
7
8
|
import { ArtifactRequestAdmission } from './gateway/admission.js';
|
|
8
9
|
import { GatewayControlAdmission } from './gateway/control-admission.js';
|
|
@@ -61,6 +62,7 @@ async function main() {
|
|
|
61
62
|
parentOrigin: process.env.ARTIFACT_NEXT_PARENT_ORIGIN ?? new URL(controlUrl).origin,
|
|
62
63
|
gatewayOrigin: required('ARTIFACT_NEXT_GATEWAY_URL'),
|
|
63
64
|
sessionKey,
|
|
65
|
+
sessionCookieSameSite: gatewaySessionSameSite({ value: process.env.ARTIFACT_NEXT_SESSION_COOKIE_SAMESITE, appEnv: namespace }),
|
|
64
66
|
contextPrivateKey: required('ARTIFACT_NEXT_CONTEXT_PRIVATE_KEY'),
|
|
65
67
|
contextKeyId: required('ARTIFACT_NEXT_CONTEXT_KEY_ID'),
|
|
66
68
|
nodeId, routing: ownership, relay,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosts a browser always resolves to this machine: the loopback literals, `localhost` and every
|
|
3
|
+
* `*.localhost` name (RFC 6761). Browsers treat plain HTTP on them as a secure context, which is
|
|
4
|
+
* what lets local development serve application origins without TLS.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isLoopbackHostname(hostname: string): boolean;
|
|
7
|
+
/** `local` and the per-worktree `local:<name>` environments seq-studio discovers. */
|
|
8
|
+
export declare function isLocalEnvironmentName(name: string): boolean;
|
package/dist/loopback.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosts a browser always resolves to this machine: the loopback literals, `localhost` and every
|
|
3
|
+
* `*.localhost` name (RFC 6761). Browsers treat plain HTTP on them as a secure context, which is
|
|
4
|
+
* what lets local development serve application origins without TLS.
|
|
5
|
+
*/
|
|
6
|
+
export function isLoopbackHostname(hostname) {
|
|
7
|
+
const host = hostname.toLowerCase();
|
|
8
|
+
return host === '127.0.0.1' || host === '[::1]' || host === 'localhost' || host.endsWith('.localhost');
|
|
9
|
+
}
|
|
10
|
+
/** `local` and the per-worktree `local:<name>` environments seq-studio discovers. */
|
|
11
|
+
export function isLocalEnvironmentName(name) {
|
|
12
|
+
return name === 'local' || name.startsWith('local:');
|
|
13
|
+
}
|
|
@@ -42,13 +42,23 @@ export declare function customRolesDeclared(block: ArtifactStudioCustomRoles | n
|
|
|
42
42
|
* manifest's `capabilities.roles.capabilities` record, or already published
|
|
43
43
|
* to the host's registry scope). Roles are never pinned — composition and
|
|
44
44
|
* grants are live runtime configuration owned by the registry.
|
|
45
|
+
*
|
|
46
|
+
* `app/<application-id>/<key>` pins a capability an Application defines in
|
|
47
|
+
* `sequence.app.yml`. It needn't exist yet (members deploy first) and counts
|
|
48
|
+
* only while the host is a member of that Application.
|
|
45
49
|
*/
|
|
46
50
|
export declare const CUSTOM_CAPABILITY_PIN_RE: RegExp;
|
|
47
51
|
export declare const capabilityPinsSchema: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
48
|
-
export
|
|
52
|
+
export type ParsedCapabilityPin = {
|
|
49
53
|
scope: 'org' | 'project';
|
|
50
54
|
key: string;
|
|
51
55
|
}
|
|
56
|
+
/** An Application capability: live only while the project is a member of `applicationId`. */
|
|
57
|
+
| {
|
|
58
|
+
scope: 'app';
|
|
59
|
+
applicationId: string;
|
|
60
|
+
key: string;
|
|
61
|
+
};
|
|
52
62
|
/** Split a pin into its scope and key. Assumes the pin grammar validated. */
|
|
53
63
|
export declare function parseCapabilityPin(pin: string): ParsedCapabilityPin;
|
|
54
64
|
/**
|