@sequenceholdings/artifact-studio 0.3.0 → 0.4.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.
Files changed (68) hide show
  1. package/README.md +37 -0
  2. package/VERSION-PIN.md +4 -0
  3. package/dist/build.js +1 -0
  4. package/dist/cli.js +34 -4
  5. package/dist/dev-link.js +1 -1
  6. package/dist/gateway/admission.d.ts +2 -1
  7. package/dist/gateway/admission.js +3 -1
  8. package/dist/gateway/authorization-cache.d.ts +52 -0
  9. package/dist/gateway/authorization-cache.js +111 -0
  10. package/dist/gateway/control-admission.d.ts +48 -0
  11. package/dist/gateway/control-admission.js +127 -0
  12. package/dist/gateway/control.d.ts +4 -1
  13. package/dist/gateway/control.js +5 -2
  14. package/dist/gateway/hosted-proxy.js +4 -7
  15. package/dist/gateway/server.d.ts +12 -0
  16. package/dist/gateway/server.js +102 -18
  17. package/dist/gateway/session.d.ts +15 -1
  18. package/dist/gateway/session.js +19 -4
  19. package/dist/gateway/usage.d.ts +45 -12
  20. package/dist/gateway/usage.js +96 -26
  21. package/dist/gateway-main.js +37 -5
  22. package/dist/loopback.d.ts +8 -0
  23. package/dist/loopback.js +13 -0
  24. package/dist/manifest-custom-roles.d.ts +11 -1
  25. package/dist/manifest-custom-roles.js +13 -5
  26. package/dist/manifest-next.js +13 -1
  27. package/dist/next/bridge.js +8 -1
  28. package/dist/next/protocol.js +2 -1
  29. package/dist/next-cache/config.js +31 -4
  30. package/dist/next-dev.js +2 -1
  31. package/dist/next-image-config.js +1 -1
  32. package/dist/next-project.js +2 -1
  33. package/dist/project.js +3 -3
  34. package/dist/role-definitions-notice.d.ts +7 -0
  35. package/dist/role-definitions-notice.js +19 -0
  36. package/dist/runtime-contracts.js +2 -1
  37. package/dist/sandbox-lint.js +1 -1
  38. package/dist/sdk-orm.d.ts +18 -0
  39. package/dist/sdk-orm.js +21 -0
  40. package/dist/sdk.d.ts +1 -0
  41. package/dist/sdk.js +1 -0
  42. package/dist/server/client.d.ts +16 -2
  43. package/dist/server/request-context.d.ts +1 -0
  44. package/dist/server/request-context.js +6 -1
  45. package/dist/server/runtime-config.d.ts +1 -0
  46. package/dist/server/runtime-config.js +2 -1
  47. package/dist/server.d.ts +1 -1
  48. package/dist/templates/nextjs/README.md +6 -0
  49. package/dist/templates/nextjs/gitignore +9 -0
  50. package/dist/templates/nextjs/pnpm-workspace.yaml +14 -0
  51. package/dist/templates/react-vite/AGENTS.md +1 -1
  52. package/dist/templates/react-vite/package.json +1 -1
  53. package/dist/trusted-install.js +62 -2
  54. package/dist/tunnel/broker.js +34 -22
  55. package/dist/tunnel/client.js +10 -3
  56. package/dist/tunnel/protocol.d.ts +9 -0
  57. package/dist/tunnel/protocol.js +33 -18
  58. package/dist/tunnel/redis-auth.d.ts +37 -0
  59. package/dist/tunnel/redis-auth.js +92 -0
  60. package/dist/tunnel/redis-types.d.ts +5 -0
  61. package/dist/tunnel/redis.d.ts +5 -2
  62. package/dist/tunnel/redis.js +19 -6
  63. package/package.json +2 -2
  64. package/templates/nextjs/README.md +6 -0
  65. package/templates/nextjs/gitignore +9 -0
  66. package/templates/nextjs/pnpm-workspace.yaml +14 -0
  67. package/templates/react-vite/AGENTS.md +1 -1
  68. package/templates/react-vite/package.json +1 -1
package/README.md CHANGED
@@ -3,6 +3,43 @@
3
3
  `@sequenceholdings/artifact-studio` exposes the host-provided `seq` client. Runtime
4
4
  requests use the current user's normal permissions.
5
5
 
6
+ ## Governed ORM operations
7
+
8
+ Use the published caller-aware adapter inside an Artifact Studio Preview or a
9
+ deployed artifact. Generate and apply the namespace first, then import its
10
+ `operations.manifest.json` into your application:
11
+
12
+ ```ts
13
+ import { createArtifactOrmExecutor } from '@sequenceholdings/artifact-studio'
14
+ import manifest from './operations.manifest.json'
15
+
16
+ const orm = createArtifactOrmExecutor({ namespace: 'team_directory', manifest })
17
+ const result = await orm.execute('Profiles', { limit: 100, offset: 0 })
18
+ ```
19
+
20
+ Declare the exact namespace path in `artifact.bundle.yml`:
21
+
22
+ ```yaml
23
+ capabilities:
24
+ api:
25
+ write: ['/api/data/v2/team_directory']
26
+ ```
27
+
28
+ Persisted queries and mutations both use POST, so both require this `write`
29
+ declaration. This declaration permits the artifact to make the request; each
30
+ user still needs namespace runtime access and the operation's role/capability.
31
+ ORM row policies and write checks enforce that caller's access. The helper uses
32
+ host authentication and requires no token, custom header, or environment URL.
33
+ The namespace path above is a capability declaration, not a direct HTTP API
34
+ contract for external clients.
35
+
36
+ The adapter validates the namespace and manifest, rejects unknown operations,
37
+ and returns the operation's `data`. Errors retain `OrmOperationError.status`,
38
+ `code`, and `errors`; missing host context or malformed success responses fail
39
+ instead of reporting saved data. Calls are never automatically retried. For
40
+ several operations in one traced UI action, pass `withTraceAction`'s client as
41
+ the optional `sdk` argument.
42
+
6
43
  Artifact Studio is a workflow-agnostic platform primitive. The host, SDK, and
7
44
  deployer provide generic application capabilities; each deployed artifact owns
8
45
  its product behavior. Primitive code must not branch on an artifact ID or import
package/VERSION-PIN.md CHANGED
@@ -17,6 +17,10 @@ introduces; they do not inherit the static compiler's aliases. The new packages
17
17
  before a fresh external Next.js scaffold can be installed. A local candidate
18
18
  build is not evidence of registry availability.
19
19
 
20
+ Artifact Studio 0.3.1 (Studio CLI 0.2.2) verifies the per-request `jti` that the
21
+ gateway now signs into every request context. The 0.3.0 context schema predates
22
+ that claim and rejects those contexts, so Next.js applications need 0.3.1 or newer.
23
+
20
24
  ## What this means
21
25
 
22
26
  `shared/services/artifact-studio/src/build.ts` force-aliases platform peers via
package/dist/build.js CHANGED
@@ -78,6 +78,7 @@ function createPeerResolveAlias({ atlasUiPackageDir = atlasUiDir } = {}) {
78
78
  'react-router-dom': reactRouterDomDir,
79
79
  sonner: sonnerDir,
80
80
  '@sequenceholdings/storage': cliRequire.resolve('@sequenceholdings/storage'),
81
+ '@sequenceholdings/orm/client': cliRequire.resolve('@sequenceholdings/orm/client'),
81
82
  // Candidate Atlas UI exposes canonical tokens at the package root; released
82
83
  // 2.0.1 keeps them in dist. Preserve explicit alias resolution so legacy
83
84
  // source reaches the normalizer instead of falling through to the bare alias.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
- import { cp, mkdir, writeFile } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
1
+ import { copyFile, cp, mkdir, writeFile } from 'node:fs/promises';
2
+ import { constants, existsSync } from 'node:fs';
3
3
  import { dirname, join, relative, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { getJson, getJsonOr404, postJson } from './api.js';
@@ -18,6 +18,7 @@ import { lintArtifactSandbox, reportSandboxWarnings } from './sandbox-lint.js';
18
18
  import { assertActiveDeployProvenance } from './active-deploy-policy.js';
19
19
  import { normalizeArtifactPath } from './paths.js';
20
20
  import yaml from 'js-yaml';
21
+ import { warnOnRoleDefinitions } from './role-definitions-notice.js';
21
22
  const COMMANDS = [
22
23
  'init', 'whoami', 'link', 'env', 'status', 'list', 'show',
23
24
  'validate', 'build', 'plan', 'deploy', 'dev', 'promote', 'pull', 'rollback',
@@ -39,6 +40,8 @@ const USAGE = `usage:
39
40
  seq-studio artifact pull <project-id> --env <env> --out <dir>
40
41
  seq-studio artifact rollback <deployment-id> --env <env>
41
42
 
43
+ Add --help or -h to any artifact command to print this usage without running it.
44
+
42
45
  Source: a local [dir] (default), a platform git-service repo (--repo <ns>/<name>),
43
46
  or a public HTTPS git URL (--git-url <url>). --ref picks a branch/tag/commit (default: the
44
47
  repo's default branch). The target project comes from the source's
@@ -71,6 +74,10 @@ export async function runCli(argv = process.argv.slice(2)) {
71
74
  'Authenticate with seq-studio login.');
72
75
  return 1;
73
76
  }
77
+ if (argv.some((arg) => arg === '--help' || arg === '-h')) {
78
+ console.log(USAGE);
79
+ return 0;
80
+ }
74
81
  const parsed = parseArgs(argv);
75
82
  if ('error' in parsed) {
76
83
  console.error(parsed.error);
@@ -134,7 +141,12 @@ async function initCommand(args) {
134
141
  ? resolve(moduleDir, 'templates', template)
135
142
  : resolve(moduleDir, '..', 'templates', template);
136
143
  await mkdir(dir, { recursive: true });
137
- await cp(templateRoot, dir, { recursive: true, force: false, errorOnExist: false });
144
+ const templateGitignore = join(templateRoot, 'gitignore');
145
+ await cp(templateRoot, dir, {
146
+ recursive: true, force: false, errorOnExist: false,
147
+ filter: (source) => source !== templateGitignore,
148
+ });
149
+ await installTemplateGitignore({ source: templateGitignore, dir });
138
150
  await replaceInFile(join(dir, 'artifact.bundle.yml'), /\{\{slug\}\}/g, slug);
139
151
  await replaceInFile(join(dir, 'artifact.bundle.yml'), /\{\{title\}\}/g, titleize(slug));
140
152
  await replaceInFile(join(dir, 'package.json'), /\{\{slug\}\}/g, slug);
@@ -494,7 +506,10 @@ async function deployCommand(args) {
494
506
  // the current branch and dirty working tree.
495
507
  if (env.name !== 'local')
496
508
  assertActiveDeployProvenance(source.provenance);
497
- if (isNextArtifactManifest(await readArtifactStudioManifest(source.dir))) {
509
+ const manifest = await readArtifactStudioManifest(source.dir);
510
+ // After source resolution, so --repo / --git-url deploys check the fetched manifest.
511
+ warnOnRoleDefinitions(manifest);
512
+ if (isNextArtifactManifest(manifest)) {
498
513
  return await buildNextCommand({ args, source, active: true });
499
514
  }
500
515
  // --skip-unchanged: hash the raw source (no build needed) and no-op when
@@ -989,6 +1004,21 @@ async function resolveAuth(options) {
989
1004
  }
990
1005
  return null;
991
1006
  }
1007
+ /**
1008
+ * npm always leaves `.gitignore` out of published tarballs, so templates ship it
1009
+ * as `gitignore`. Install it without replacing an ignore file the directory has.
1010
+ */
1011
+ async function installTemplateGitignore({ source, dir }) {
1012
+ if (!existsSync(source))
1013
+ return;
1014
+ try {
1015
+ await copyFile(source, join(dir, '.gitignore'), constants.COPYFILE_EXCL);
1016
+ }
1017
+ catch (error) {
1018
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST'))
1019
+ throw error;
1020
+ }
1021
+ }
992
1022
  async function replaceInFile(path, pattern, value) {
993
1023
  const content = await import('node:fs/promises').then((fs) => fs.readFile(path, 'utf8'));
994
1024
  await writeFile(path, content.replace(pattern, value), 'utf8');
package/dist/dev-link.js CHANGED
@@ -8,7 +8,7 @@ import { dirname, join, relative, resolve } from 'node:path';
8
8
  export function hasLinkedAtlasUiToolchain({ deps }) {
9
9
  return deps.some((dep) => dep.name === '@sequenceholdings/atlas-ui');
10
10
  }
11
- const WATCHED_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.css', '.json']);
11
+ const WATCHED_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.css', '.json']);
12
12
  const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.turbo', '.cache']);
13
13
  const defaultResolver = createRequire(import.meta.url).resolve;
14
14
  /** Split a `--link a,b` flag value into trimmed, non-empty paths. */
@@ -34,7 +34,8 @@ export interface AdmissionLease {
34
34
  export declare class ArtifactAdmissionError extends Error {
35
35
  readonly status: 429 | 503;
36
36
  readonly code: 'capacity_exceeded' | 'admission_unavailable';
37
- constructor(status: 429 | 503, code: 'capacity_exceeded' | 'admission_unavailable');
37
+ readonly retryAfterSeconds?: number | undefined;
38
+ constructor(status: 429 | 503, code: 'capacity_exceeded' | 'admission_unavailable', retryAfterSeconds?: number | undefined);
38
39
  }
39
40
  /** Fail-closed admission with no command retries. An uncertain acquisition is
40
41
  * never used; its unique permit expires even if Redis accepted it before loss. */
@@ -6,10 +6,12 @@ import { redisNamespaceSchema } from '../tunnel/redis-ownership.js';
6
6
  export class ArtifactAdmissionError extends Error {
7
7
  status;
8
8
  code;
9
- constructor(status, code) {
9
+ retryAfterSeconds;
10
+ constructor(status, code, retryAfterSeconds) {
10
11
  super(status === 429 ? 'Application request capacity exceeded' : 'Application request admission unavailable');
11
12
  this.status = status;
12
13
  this.code = code;
14
+ this.retryAfterSeconds = retryAfterSeconds;
13
15
  this.name = 'ArtifactAdmissionError';
14
16
  }
15
17
  }
@@ -0,0 +1,52 @@
1
+ import type { GatewaySession } from './session.js';
2
+ /**
3
+ * How long the gateway reuses an Atlas "allow" for the same session and target. Revocation,
4
+ * suspension, rollback, retirement, rollout disablement and actor deactivation reach browsers
5
+ * that are already admitted within this window. Denials are never cached.
6
+ */
7
+ export declare const GATEWAY_AUTHORIZATION_TTL_MS = 5000;
8
+ type Lookup<T> = {
9
+ key: string;
10
+ load: () => Promise<T>;
11
+ notAfter: (value: T) => number;
12
+ };
13
+ /**
14
+ * Per-process, positive-only decision cache with single-flight loading: the burst of chunk
15
+ * requests behind one page load costs one control-plane call per gateway replica per window.
16
+ * An entry never outlives the decision's own expiry. Every answer is ordered by when it was
17
+ * asked, so an older allow that resolves late can never replace a newer answer, and a refusal
18
+ * also detaches any older lookup still in flight.
19
+ */
20
+ export declare class PositiveDecisionCache<T> {
21
+ private readonly entries;
22
+ private readonly pending;
23
+ private readonly ttlMs;
24
+ private readonly maxEntries;
25
+ private readonly now;
26
+ private sequence;
27
+ constructor(options?: {
28
+ ttlMs?: number;
29
+ maxEntries?: number;
30
+ now?: () => number;
31
+ });
32
+ /** `notAfter` bounds the entry by the decision's own validity, such as a session's expiry. */
33
+ get(lookup: Lookup<T>): Promise<T>;
34
+ /**
35
+ * Always asks the source, for decisions that must be current (a mutation). An allow refreshes the
36
+ * cached entry; a refusal evicts it and fences older lookups, so the next read asks again too.
37
+ */
38
+ refresh(lookup: Lookup<T>): Promise<T>;
39
+ clear(): void;
40
+ private ask;
41
+ private record;
42
+ }
43
+ /** Every session field Atlas authorizes or the gateway checks the answer against. Tab-local
44
+ * bridge values and the cookie's own expiry do not change the decision. */
45
+ export declare function gatewayAuthorizationKey(session: GatewaySession): string;
46
+ /** A tunnel credential is keyed by its hash, never retained in the key itself. */
47
+ export declare function tunnelAuthorizationKey({ token, sessionId, epoch }: {
48
+ token: string;
49
+ sessionId: string;
50
+ epoch: number;
51
+ }): string;
52
+ export {};
@@ -0,0 +1,111 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * How long the gateway reuses an Atlas "allow" for the same session and target. Revocation,
4
+ * suspension, rollback, retirement, rollout disablement and actor deactivation reach browsers
5
+ * that are already admitted within this window. Denials are never cached.
6
+ */
7
+ export const GATEWAY_AUTHORIZATION_TTL_MS = 5_000;
8
+ const DEFAULT_MAX_ENTRIES = 10_000;
9
+ /**
10
+ * Per-process, positive-only decision cache with single-flight loading: the burst of chunk
11
+ * requests behind one page load costs one control-plane call per gateway replica per window.
12
+ * An entry never outlives the decision's own expiry. Every answer is ordered by when it was
13
+ * asked, so an older allow that resolves late can never replace a newer answer, and a refusal
14
+ * also detaches any older lookup still in flight.
15
+ */
16
+ export class PositiveDecisionCache {
17
+ entries = new Map();
18
+ pending = new Map();
19
+ ttlMs;
20
+ maxEntries;
21
+ now;
22
+ sequence = 0;
23
+ constructor(options = {}) {
24
+ this.ttlMs = options.ttlMs ?? GATEWAY_AUTHORIZATION_TTL_MS;
25
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
26
+ this.now = options.now ?? (() => Date.now());
27
+ if (!Number.isSafeInteger(this.ttlMs) || this.ttlMs < 0 || this.ttlMs > 30_000)
28
+ throw new Error('Authorization cache TTL must be between 0 and 30 seconds');
29
+ if (!Number.isSafeInteger(this.maxEntries) || this.maxEntries < 1)
30
+ throw new Error('Authorization cache size must be positive');
31
+ }
32
+ /** `notAfter` bounds the entry by the decision's own validity, such as a session's expiry. */
33
+ get(lookup) {
34
+ const cached = this.entries.get(lookup.key);
35
+ if (cached && !cached.refused && cached.expiresAt > this.now())
36
+ return Promise.resolve(cached.value);
37
+ const inFlight = this.pending.get(lookup.key);
38
+ if (inFlight)
39
+ return inFlight.promise;
40
+ return this.ask({ lookup, share: true });
41
+ }
42
+ /**
43
+ * Always asks the source, for decisions that must be current (a mutation). An allow refreshes the
44
+ * cached entry; a refusal evicts it and fences older lookups, so the next read asks again too.
45
+ */
46
+ refresh(lookup) {
47
+ return this.ask({ lookup, share: false });
48
+ }
49
+ clear() {
50
+ this.entries.clear();
51
+ this.pending.clear();
52
+ }
53
+ ask({ lookup: { key, load, notAfter }, share }) {
54
+ const sequence = ++this.sequence;
55
+ const askedAt = this.now();
56
+ const promise = load().then((value) => {
57
+ // Expiry counts from when the question was asked, so an answer is never reused past the TTL.
58
+ this.record({ key, entry: { sequence, refused: false, value, expiresAt: Math.min(askedAt + this.ttlMs, notAfter(value)) } });
59
+ return value;
60
+ }, (error) => {
61
+ this.record({ key, entry: { sequence, refused: true, expiresAt: this.now() + this.ttlMs } });
62
+ throw error;
63
+ });
64
+ if (share) {
65
+ const flight = { sequence, promise };
66
+ this.pending.set(key, flight);
67
+ void promise.finally(() => { if (this.pending.get(key) === flight)
68
+ this.pending.delete(key); }).catch(() => undefined);
69
+ }
70
+ return promise;
71
+ }
72
+ record({ key, entry }) {
73
+ const current = this.entries.get(key);
74
+ // A newer answer, allow or refusal, always wins over one that was asked earlier.
75
+ if (current && current.sequence > entry.sequence)
76
+ return;
77
+ this.entries.delete(key);
78
+ if (entry.refused) {
79
+ // New readers must not join a lookup that started before this refusal.
80
+ const flight = this.pending.get(key);
81
+ if (flight && flight.sequence < entry.sequence)
82
+ this.pending.delete(key);
83
+ // An older allow could only have been reused for one TTL, so the fence lives that long.
84
+ if (this.ttlMs === 0)
85
+ return;
86
+ }
87
+ else if (entry.expiresAt <= this.now())
88
+ return;
89
+ this.entries.set(key, entry);
90
+ // Maps iterate in insertion order, so the first key is the least recently stored.
91
+ while (this.entries.size > this.maxEntries) {
92
+ const oldest = this.entries.keys().next().value;
93
+ if (oldest === undefined)
94
+ break;
95
+ this.entries.delete(oldest);
96
+ }
97
+ }
98
+ }
99
+ function digest(parts) {
100
+ return createHash('sha256').update(JSON.stringify(parts)).digest('hex');
101
+ }
102
+ /** Every session field Atlas authorizes or the gateway checks the answer against. Tab-local
103
+ * bridge values and the cookie's own expiry do not change the decision. */
104
+ export function gatewayAuthorizationKey(session) {
105
+ return digest(['session-v1', session.kind, session.projectId, session.ownerId, session.environment, session.publicOrigin,
106
+ session.sessionId, session.deploymentId, session.frameworkBuildId, session.published, session.lattice ?? null, session.taskRoutes ?? null]);
107
+ }
108
+ /** A tunnel credential is keyed by its hash, never retained in the key itself. */
109
+ export function tunnelAuthorizationKey({ token, sessionId, epoch }) {
110
+ return digest(['tunnel-v1', sessionId, epoch, createHash('sha256').update(token).digest('hex')]);
111
+ }
@@ -0,0 +1,48 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { type AdmissionRedis } from './admission.js';
3
+ export type GatewayControlEndpoint = 'session' | 'tunnel';
4
+ type EndpointLimits = {
5
+ perClient: number;
6
+ global: number;
7
+ };
8
+ export interface GatewayControlAdmissionLimits {
9
+ /** Fixed Redis-clock window shared by the per-client and environment-wide counters. */
10
+ windowMs: number;
11
+ session: EndpointLimits;
12
+ tunnel: EndpointLimits;
13
+ /** Unauthenticated control calls one gateway process may have outstanding against Atlas. */
14
+ maxInFlight: number;
15
+ }
16
+ /**
17
+ * Budgets for the only gateway endpoints that reach Atlas before a session exists. The per-client
18
+ * allowance tolerates many users behind one corporate egress address; the environment-wide budget
19
+ * bounds what any number of sources can cost Atlas, whatever their addresses.
20
+ */
21
+ export declare const GATEWAY_CONTROL_ADMISSION_LIMITS: Readonly<GatewayControlAdmissionLimits>;
22
+ /**
23
+ * Admits `/_sequence/session` and `/_sequence/tunnel` before they call Atlas. Fails closed, with no
24
+ * command retries: when Redis cannot answer, nothing unauthenticated reaches the control plane.
25
+ */
26
+ export declare class GatewayControlAdmission {
27
+ private readonly options;
28
+ private readonly namespace;
29
+ private readonly limits;
30
+ private inFlight;
31
+ constructor(options: {
32
+ redis: AdmissionRedis;
33
+ namespace: string;
34
+ limits?: GatewayControlAdmissionLimits;
35
+ });
36
+ /** Resolves to the in-flight permit's release; throws ArtifactAdmissionError (429 or 503). */
37
+ admit({ endpoint, client }: {
38
+ endpoint: GatewayControlEndpoint;
39
+ client: string;
40
+ }): Promise<() => void>;
41
+ }
42
+ /**
43
+ * The address a control call is budgeted against. Cloudflare sets CF-Connecting-IP and the edge
44
+ * Cloud Armor policy admits only Cloudflare. If that policy were bypassed, a forged address could
45
+ * only move a caller between per-client buckets: the environment-wide budget still applies.
46
+ */
47
+ export declare function gatewayClientAddress(request: IncomingMessage): string;
48
+ export {};
@@ -0,0 +1,127 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isIP, isIPv6 } from 'node:net';
3
+ import { z } from 'zod';
4
+ import { redisNamespaceSchema } from '../tunnel/redis-ownership.js';
5
+ import { ArtifactAdmissionError } from './admission.js';
6
+ /**
7
+ * Budgets for the only gateway endpoints that reach Atlas before a session exists. The per-client
8
+ * allowance tolerates many users behind one corporate egress address; the environment-wide budget
9
+ * bounds what any number of sources can cost Atlas, whatever their addresses.
10
+ */
11
+ export const GATEWAY_CONTROL_ADMISSION_LIMITS = Object.freeze({
12
+ windowMs: 10_000,
13
+ session: Object.freeze({ perClient: 60, global: 600 }),
14
+ tunnel: Object.freeze({ perClient: 30, global: 300 }),
15
+ maxInFlight: 64,
16
+ });
17
+ // Both keys share one hash tag, so the script stays single-slot on Redis Cluster. A rejected
18
+ // attempt consumes nothing: one noisy client cannot spend the environment-wide budget.
19
+ const ADMIT = `-- artifact-gateway-control-admission-v1
20
+ local window = tonumber(ARGV[3])
21
+ for index, key in ipairs(KEYS) do
22
+ if tonumber(redis.call('GET', key) or '0') >= tonumber(ARGV[index]) then
23
+ local ttl = redis.call('PTTL', key)
24
+ if ttl < 0 then
25
+ redis.call('PEXPIRE', key, window)
26
+ ttl = window
27
+ end
28
+ return {0, ttl}
29
+ end
30
+ end
31
+ for _, key in ipairs(KEYS) do
32
+ if redis.call('INCR', key) == 1 or redis.call('PTTL', key) < 0 then redis.call('PEXPIRE', key, window) end
33
+ end
34
+ return {1, 0}`;
35
+ const limitSchema = z.number().int().min(1).max(1_000_000);
36
+ const limitsSchema = z.object({
37
+ windowMs: z.number().int().min(1_000).max(3_600_000),
38
+ session: z.object({ perClient: limitSchema, global: limitSchema }),
39
+ tunnel: z.object({ perClient: limitSchema, global: limitSchema }),
40
+ maxInFlight: z.number().int().min(1).max(10_000),
41
+ });
42
+ const replySchema = z.tuple([z.number().int().min(0).max(1), z.number().int().min(0)]);
43
+ /**
44
+ * Admits `/_sequence/session` and `/_sequence/tunnel` before they call Atlas. Fails closed, with no
45
+ * command retries: when Redis cannot answer, nothing unauthenticated reaches the control plane.
46
+ */
47
+ export class GatewayControlAdmission {
48
+ options;
49
+ namespace;
50
+ limits;
51
+ inFlight = 0;
52
+ constructor(options) {
53
+ this.options = options;
54
+ this.namespace = redisNamespaceSchema.parse(options.namespace);
55
+ this.limits = limitsSchema.parse(options.limits ?? GATEWAY_CONTROL_ADMISSION_LIMITS);
56
+ const policy = options.redis.options;
57
+ if (policy.enableOfflineQueue !== false || policy.autoResendUnfulfilledCommands !== false || policy.maxRetriesPerRequest !== 0) {
58
+ throw new Error('Control admission Redis must disable offline queues, command replay and retries');
59
+ }
60
+ }
61
+ /** Resolves to the in-flight permit's release; throws ArtifactAdmissionError (429 or 503). */
62
+ async admit({ endpoint, client }) {
63
+ if (this.inFlight >= this.limits.maxInFlight)
64
+ throw new ArtifactAdmissionError(503, 'admission_unavailable');
65
+ this.inFlight++;
66
+ let released = false;
67
+ const release = () => {
68
+ if (released)
69
+ return;
70
+ released = true;
71
+ this.inFlight--;
72
+ };
73
+ try {
74
+ const limits = this.limits[endpoint];
75
+ const prefix = `artifact-gateway-control:{${this.namespace}}:${endpoint}`;
76
+ const bucket = createHash('sha256').update(clientBucket(client)).digest('hex').slice(0, 32);
77
+ let reply;
78
+ try {
79
+ reply = replySchema.parse(await this.options.redis.eval(ADMIT, 2, `${prefix}:client:${bucket}`, `${prefix}:global`, limits.perClient, limits.global, this.limits.windowMs));
80
+ }
81
+ catch {
82
+ throw new ArtifactAdmissionError(503, 'admission_unavailable');
83
+ }
84
+ if (reply[0] !== 1) {
85
+ throw new ArtifactAdmissionError(429, 'capacity_exceeded', Math.max(1, Math.ceil(reply[1] / 1000)));
86
+ }
87
+ return release;
88
+ }
89
+ catch (error) {
90
+ release();
91
+ throw error;
92
+ }
93
+ }
94
+ }
95
+ /** One IPv6 host usually controls a whole /64, so it shares one allowance. */
96
+ function clientBucket(address) {
97
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
98
+ if (mapped)
99
+ return mapped[1];
100
+ if (!isIPv6(address))
101
+ return address;
102
+ const [head = '', tail] = address.toLowerCase().split('::');
103
+ const leading = head ? head.split(':') : [];
104
+ const trailing = tail ? tail.split(':') : [];
105
+ const groups = tail === undefined ? leading : [...leading, ...Array(Math.max(0, 8 - leading.length - trailing.length)).fill('0'), ...trailing];
106
+ return `${groups.slice(0, 4).map((group) => group.padStart(4, '0')).join(':')}::/64`;
107
+ }
108
+ function forwardedClient(request) {
109
+ const value = request.headers['x-forwarded-for'];
110
+ if (typeof value !== 'string')
111
+ return undefined;
112
+ // Google's external load balancer appends "<connecting client>, <load balancer>".
113
+ const hops = value.split(',').map((hop) => hop.trim()).filter(Boolean);
114
+ const candidate = hops.at(-2);
115
+ return candidate && isIP(candidate) ? candidate : undefined;
116
+ }
117
+ /**
118
+ * The address a control call is budgeted against. Cloudflare sets CF-Connecting-IP and the edge
119
+ * Cloud Armor policy admits only Cloudflare. If that policy were bypassed, a forged address could
120
+ * only move a caller between per-client buckets: the environment-wide budget still applies.
121
+ */
122
+ export function gatewayClientAddress(request) {
123
+ const cloudflare = request.headers['cf-connecting-ip'];
124
+ if (typeof cloudflare === 'string' && isIP(cloudflare.trim()))
125
+ return cloudflare.trim();
126
+ return forwardedClient(request) ?? request.socket.remoteAddress ?? 'unknown';
127
+ }
@@ -77,7 +77,10 @@ export declare class ArtifactGatewayControl {
77
77
  fetch?: typeof fetch;
78
78
  });
79
79
  private call;
80
- usage(event: GatewayUsageEvent): Promise<void>;
80
+ /** One idempotent batch; Atlas reports events it refused (for example, a retired target). */
81
+ usage(events: GatewayUsageEvent[]): Promise<{
82
+ rejected: number;
83
+ }>;
81
84
  bootstrap(ticket: string): Promise<{
82
85
  session: GatewaySession;
83
86
  target: GatewayTarget;
@@ -19,6 +19,7 @@ export const gatewayTargetSchema = z.discriminatedUnion('kind', [
19
19
  z.object({ ...base, kind: z.literal('hosted'), deploymentId: id, frameworkBuildId: artifactNextFrameworkBuildIdSchema, upstreamUrl: z.string().url(), upstreamAudience: z.string().url().optional(), active: z.boolean(), published: z.boolean().optional() }),
20
20
  ]);
21
21
  const bootstrapSchema = z.object({ parentOrigin: z.string().transform(normalizePublicOrigin), channelId: id, nonce: z.string().min(16).max(255) });
22
+ const usageResultSchema = z.object({ rejected: z.number().int().nonnegative() });
22
23
  export class GatewayControlError extends Error {
23
24
  status;
24
25
  constructor(status) {
@@ -69,8 +70,10 @@ export class ArtifactGatewayControl {
69
70
  await reader.cancel().catch(() => undefined);
70
71
  }
71
72
  }
72
- async usage(event) {
73
- await this.call('usage', event, 2_000);
73
+ /** One idempotent batch; Atlas reports events it refused (for example, a retired target). */
74
+ async usage(events) {
75
+ const value = await this.call('usage', { events }, 5_000);
76
+ return usageResultSchema.parse(value);
74
77
  }
75
78
  async bootstrap(ticket) {
76
79
  const value = await this.call('bootstrap', { ticket });
@@ -3,7 +3,7 @@ import { request as httpsRequest } from 'node:https';
3
3
  import { Duplex, Readable, Transform } from 'node:stream';
4
4
  import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';
5
5
  import { GoogleAuth } from 'google-auth-library';
6
- import { assertSafeRequest, normalizePublicOrigin, sanitizeRequestHeaders, sanitizeResponseHeaders } from '../tunnel/protocol.js';
6
+ import { assertSafeRequest, isSharedCacheDirective, normalizePublicOrigin, sanitizeRequestHeaders, sanitizeResponseHeaders, SHARED_CACHE_REFUSAL } from '../tunnel/protocol.js';
7
7
  import { createReleaseFormTransform } from '../server/release-forms.js';
8
8
  export const HOSTED_ARTIFACT_PROXY_LIMITS = Object.freeze({ maxBodyBytes: 32 * 1024 * 1024, timeoutMs: 60_000, maxTimeoutMs: 300_000 });
9
9
  const googleAuth = new GoogleAuth();
@@ -77,15 +77,12 @@ function enforceBrowserCachePolicy({ request, response }) {
77
77
  // Every protected reuse must reach authorization. Retain only private static
78
78
  // JS/CSS bytes and validators; app output may never opt into browser/CDN TTLs.
79
79
  for (const name of response.getHeaderNames()) {
80
- if (/(?:^|-)cdn-cache-control$/.test(name) || ['surrogate-control', 'x-accel-expires', 'expires', 'age'].includes(name)) {
80
+ if (isSharedCacheDirective(name))
81
81
  response.removeHeader(name);
82
- }
83
82
  }
84
83
  response.setHeader('cache-control', revalidateAsset ? 'private, no-cache' : 'private, no-store');
85
- response.setHeader('cdn-cache-control', 'no-store');
86
- response.setHeader('surrogate-control', 'no-store');
87
- response.setHeader('pragma', 'no-cache');
88
- response.setHeader('expires', '0');
84
+ for (const [name, value] of Object.entries(SHARED_CACHE_REFUSAL))
85
+ response.setHeader(name, value);
89
86
  }
90
87
  /** HTTP-only streaming hop. Authentication, CSRF and release admission happen before calling this. */
91
88
  export function proxyHostedArtifact(options) {
@@ -1,13 +1,17 @@
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';
6
+ import { type GatewayControlAdmission } from './control-admission.js';
5
7
  export interface ArtifactGatewayOptions {
6
8
  control: ArtifactGatewayControl;
7
9
  runtimeDomain: string;
8
10
  parentOrigin: string;
9
11
  gatewayOrigin: string;
10
12
  sessionKey: string;
13
+ /** Defaults to `Strict`. `None` only for a local development gateway (see GatewaySessionSameSite). */
14
+ sessionCookieSameSite?: GatewaySessionSameSite;
11
15
  contextPrivateKey: string;
12
16
  contextKeyId: string;
13
17
  /** Unique per process, never a shared Cloud Run revision name. */
@@ -18,12 +22,20 @@ export interface ArtifactGatewayOptions {
18
22
  renewLease?: (lease: TunnelLease) => Promise<boolean>;
19
23
  ready?: () => boolean;
20
24
  admission?: Pick<ArtifactRequestAdmission, 'acquire'>;
25
+ /** Rate limits `/_sequence/session` and `/_sequence/tunnel` before they reach Atlas. Without it
26
+ * both endpoints fail closed. */
27
+ controlAdmission?: Pick<GatewayControlAdmission, 'admit'>;
28
+ /** Reuse of a positive Atlas authorization (default GATEWAY_AUTHORIZATION_TTL_MS; 0 disables). */
29
+ authorizationCacheTtlMs?: number;
21
30
  onError?: (event: {
22
31
  kind: string;
23
32
  requestId?: string;
33
+ count?: number;
24
34
  }) => void;
25
35
  }
26
36
  export declare function createArtifactGateway(options: ArtifactGatewayOptions): {
27
37
  server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
38
+ /** Observations recorded but not yet confirmed by Atlas: what a forced exit would lose. */
39
+ readonly pendingUsage: number;
28
40
  drain(): Promise<void>;
29
41
  };