@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
@@ -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 interface ParsedCapabilityPin {
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
  /**
@@ -158,18 +158,26 @@ export function customRolesDeclared(block) {
158
158
  * manifest's `capabilities.roles.capabilities` record, or already published
159
159
  * to the host's registry scope). Roles are never pinned — composition and
160
160
  * grants are live runtime configuration owned by the registry.
161
+ *
162
+ * `app/<application-id>/<key>` pins a capability an Application defines in
163
+ * `sequence.app.yml`. It needn't exist yet (members deploy first) and counts
164
+ * only while the host is a member of that Application.
161
165
  */
162
- export const CUSTOM_CAPABILITY_PIN_RE = /^(?:org\/)?[a-z0-9-]+$/;
166
+ export const CUSTOM_CAPABILITY_PIN_RE = /^(?:org\/|app\/[a-z][a-z0-9-]{0,63}\/)?[a-z0-9-]+$/;
163
167
  const MAX_CAPABILITY_PINS = 100;
164
168
  export const capabilityPinsSchema = z
165
- .array(z.string().min(1).max(68).regex(CUSTOM_CAPABILITY_PIN_RE, 'Pins must be "org/<key>" or a bare "<key>" matching [a-z0-9-]+'))
169
+ .array(z.string().min(1).max(140).regex(CUSTOM_CAPABILITY_PIN_RE, 'Pins must be "org/<key>", "app/<application-id>/<key>", or a bare "<key>" matching [a-z0-9-]+'))
166
170
  .max(MAX_CAPABILITY_PINS)
167
171
  .default([]);
168
172
  /** Split a pin into its scope and key. Assumes the pin grammar validated. */
169
173
  export function parseCapabilityPin(pin) {
170
- return pin.startsWith('org/')
171
- ? { scope: 'org', key: pin.slice('org/'.length) }
172
- : { scope: 'project', key: pin };
174
+ if (pin.startsWith('org/'))
175
+ return { scope: 'org', key: pin.slice('org/'.length) };
176
+ if (pin.startsWith('app/')) {
177
+ const [, applicationId = '', key = ''] = pin.split('/');
178
+ return { scope: 'app', applicationId, key };
179
+ }
180
+ return { scope: 'project', key: pin };
173
181
  }
174
182
  /**
175
183
  * Cross-field pin validation, called from the manifest superRefine (the
@@ -18,9 +18,21 @@ const apiPath = z.string().min(1).max(512).refine((value) => {
18
18
  return false;
19
19
  return value.split('/').slice(1).every((part, index, parts) => part !== '.' && part !== '..' && (part !== '' || index === parts.length - 1));
20
20
  }, 'API capability paths must be scoped below /api/ without encoded paths, traversal, queries, or fragments');
21
+ // ORM references are what a delegated data token covers (orm-data-plane checkDataTokenScope):
22
+ // a query needs namespace/table for every table it reads, and a mutation needs its
23
+ // namespace/ops/Operation (or table coverage). Any other shape can never match, so it is
24
+ // a typo to report here rather than a 403 at request time. The patterns mirror the ORM's
25
+ // NAMESPACE_PATTERN, IDENTIFIER_PATTERN and GraphQL operation names; they are kept local so
26
+ // browser code that parses manifests does not load the ORM package. platformUsers is the
27
+ // ORM's built-in platform-user entity (PLATFORM_USER_TABLE), a logical key outside the
28
+ // identifier pattern that queries traversing user relations read.
29
+ const ormTableRef = /^[a-z][a-z0-9_]{0,40}\/(?:[a-z][a-z0-9_]{0,62}|platformUsers)$/;
30
+ const ormOperationRef = /^[a-z][a-z0-9_]{0,40}\/ops\/[_A-Za-z][_0-9A-Za-z]*$/;
31
+ const dataReadRef = boundedRef.refine((ref) => ormTableRef.test(ref), 'ORM read references use namespace/table');
32
+ const dataWriteRef = boundedRef.refine((ref) => ormTableRef.test(ref) || ormOperationRef.test(ref), 'ORM write references use namespace/ops/Operation or namespace/table');
21
33
  const reachShape = {
22
34
  storage: storageScopesSchema.optional(),
23
- data: z.strictObject({ read: z.array(boundedRef).max(256).default([]), write: z.array(boundedRef).max(256).default([]) }).prefault({}),
35
+ data: z.strictObject({ read: z.array(dataReadRef).max(256).default([]), write: z.array(dataWriteRef).max(256).default([]) }).prefault({}),
24
36
  api: z.strictObject({ read: z.array(apiPath).max(256).default([]), write: z.array(apiPath).max(256).default([]) }).prefault({}),
25
37
  functions: z.strictObject({ invoke: z.array(boundedRef).max(256).default([]) }).prefault({}),
26
38
  };
@@ -103,7 +103,14 @@ export function createNextArtifactBridge(options) {
103
103
  if (ready)
104
104
  return ready;
105
105
  if (!transport) {
106
- transport = createNextArtifactWindowTransport();
106
+ // Report a missing Atlas parent frame like every other connection failure,
107
+ // so a caller's promise handler sees it instead of an uncaught throw.
108
+ try {
109
+ transport = createNextArtifactWindowTransport();
110
+ }
111
+ catch (error) {
112
+ return Promise.reject(error instanceof Error ? error : new Error('Artifact bridge transport unavailable'));
113
+ }
107
114
  uninstallReleaseGuard = installArtifactReleaseGuard({ deploymentId: bootstrap.deploymentId ?? null });
108
115
  }
109
116
  ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { stripArtifactReleaseQuery } from './routes.js';
3
+ import { isLoopbackHostname } from '../loopback.js';
3
4
  export const NEXT_ARTIFACT_BRIDGE_TYPE = 'artifact-studio-next';
4
5
  export const NEXT_ARTIFACT_BRIDGE_VERSION = 1;
5
6
  export const nextArtifactThemeSchema = z.object({
@@ -44,7 +45,7 @@ export function validateNextArtifactBootstrap({ bootstrap }) {
44
45
  catch {
45
46
  throw new Error('Artifact parent origin must be an exact HTTP(S) origin');
46
47
  }
47
- const local = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]';
48
+ const local = isLoopbackHostname(parsed.hostname);
48
49
  if (parsed.origin !== bootstrap.parentOrigin || (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && local))) {
49
50
  throw new Error('Artifact parent origin must be HTTPS (or HTTP loopback), without a path');
50
51
  }
@@ -1,10 +1,12 @@
1
1
  import { readFile, readdir, rename, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { load as parseYaml } from 'js-yaml';
5
+ import { isNextArtifactManifest, parseArtifactStudioManifest } from '../manifest.js';
4
6
  import { resolveArtifactNextImages } from '../next-image-config.js';
5
7
  export { artifactNextCacheBindingSchema, verifyArtifactNextCacheReadiness } from './runtime.js';
6
8
  const MARKER = '// Generated by Atlas Artifact Studio; original configuration is preserved.';
7
- function configured(config) {
9
+ function configured(config, egress) {
8
10
  const deploymentId = process.env.ARTIFACT_NEXT_BUILD_ID;
9
11
  if (deploymentId !== undefined && (deploymentId.length < 1 || deploymentId.length > 128 || /[^A-Za-z0-9_-]/.test(deploymentId)))
10
12
  throw new Error('Invalid platform build identifier');
@@ -25,7 +27,6 @@ function configured(config) {
25
27
  || Object.entries(handlers).some(([key, value]) => !['default', 'remote'].includes(key) || value !== components))) {
26
28
  throw new Error('Artifact Studio owns the Next cacheHandlers configuration');
27
29
  }
28
- const egress = JSON.parse(process.env.ARTIFACT_NEXT_ALLOWED_EGRESS ?? '[]');
29
30
  if (!Array.isArray(egress) || !egress.every((host) => typeof host === 'string'))
30
31
  throw new Error('Invalid Artifact image egress policy');
31
32
  const images = resolveArtifactNextImages({ images: Reflect.get(config, 'images'), egressHosts: egress });
@@ -34,6 +35,32 @@ function configured(config) {
34
35
  cacheHandler: incremental, cacheHandlers: { default: components, remote: components },
35
36
  images: { ...images, customCacheHandler: true } };
36
37
  }
38
+ /**
39
+ * seq-studio and the hosted build supply the application's egress policy. A plain local
40
+ * `next build` or `next dev` reads the same declaration from the application's manifest.
41
+ */
42
+ async function allowedEgress(config) {
43
+ const supplied = process.env.ARTIFACT_NEXT_ALLOWED_EGRESS;
44
+ if (supplied !== undefined)
45
+ return JSON.parse(supplied);
46
+ const images = Reflect.get(config, 'images');
47
+ const patterns = typeof images === 'object' && images !== null ? Reflect.get(images, 'remotePatterns') : undefined;
48
+ if (process.env.K_SERVICE || !Array.isArray(patterns) || patterns.length === 0)
49
+ return [];
50
+ return declaredEgress({ projectRoot: process.cwd() });
51
+ }
52
+ /** The nearest artifact.bundle.yml, when its app_root is this project; the manifest may sit above app_root. */
53
+ async function declaredEgress({ projectRoot }) {
54
+ for (let directory = projectRoot;; directory = dirname(directory)) {
55
+ const text = await readFile(join(directory, 'artifact.bundle.yml'), 'utf8').catch(() => undefined);
56
+ if (text !== undefined) {
57
+ const manifest = parseArtifactStudioManifest(parseYaml(text));
58
+ return isNextArtifactManifest(manifest) && resolve(directory, manifest.artifact.app_root) === projectRoot ? manifest.server.egress : [];
59
+ }
60
+ if (dirname(directory) === directory)
61
+ return [];
62
+ }
63
+ }
37
64
  /** Always compiled into standalone output; runtime mode is selected only at boot. */
38
65
  export function withArtifactNextConfig(input) {
39
66
  return async (phase, context) => {
@@ -48,7 +75,7 @@ export function withArtifactNextConfig(input) {
48
75
  const config = typeof input === 'function' ? await input(phase, context) : input;
49
76
  if (!config || typeof config !== 'object' || Array.isArray(config))
50
77
  throw new Error('Next configuration must return an object');
51
- return configured(config);
78
+ return configured(config, await allowedEgress(config));
52
79
  };
53
80
  }
54
81
  /** Runs only inside the isolated compile sandbox, never in the control-plane worker. */
package/dist/next-dev.js CHANGED
@@ -14,6 +14,7 @@ import { materializeNextPublicFonts } from './next-public-fonts-local.js';
14
14
  import { isTransientNextControlFailure, requestNextDevLease, renewNextDevLease, writeNextRuntimeFile } from './next-dev-control.js';
15
15
  import { openNextPreview, selectNextDevPort, startNextDevProcess } from './next-dev-process.js';
16
16
  import { connectLocalTunnel } from './tunnel/index.js';
17
+ import { isLocalEnvironmentName } from './loopback.js';
17
18
  /** Shutdown never waits longer than this on Atlas; an unconfirmed session still expires with its lease. */
18
19
  const SESSION_REVOCATION_TIMEOUT_MS = 10_000;
19
20
  export async function runNextDevelopment({ root, manifest, projectId, baseUrl, environment, previewKey, port: requestedPort, links, getToken }) {
@@ -218,7 +219,7 @@ async function runTunnelConnections({ lease: initialLease, environment, port, re
218
219
  try {
219
220
  const connection = await connectLocalTunnel({
220
221
  url: lease.tunnel.url, token: lease.tunnel.token, sessionId: lease.session.id, epoch: lease.tunnel.epoch,
221
- localPort: port, publicOrigin: lease.session.publicOrigin, signal, allowInsecureLoopback: environment === 'local',
222
+ localPort: port, publicOrigin: lease.session.publicOrigin, signal, allowInsecureLoopback: isLocalEnvironmentName(environment),
222
223
  });
223
224
  attempt = 0;
224
225
  onStatus('ready');
@@ -66,7 +66,7 @@ function resolveRemotePattern({ pattern, index, egressHosts }) {
66
66
  throw new Error(`${label} requires an exact public HTTPS hostname on port 443; wildcard, IP and local hosts are unsupported`);
67
67
  }
68
68
  if (!egressHosts.includes(value.hostname)) {
69
- throw new Error(`${label} hostname must be declared in server.egress; run seq-studio artifact dev/build to supply the application's image policy`);
69
+ throw new Error(`${label} hostname ${value.hostname} must be declared in the application's server.egress in artifact.bundle.yml`);
70
70
  }
71
71
  const pathname = remotePatternPathname({ pathname: value.pathname ?? '/**', label });
72
72
  const search = remotePatternSearch({ search: value.search, label });
@@ -5,6 +5,7 @@ import { join, resolve } from 'node:path';
5
5
  import { z } from 'zod';
6
6
  import { createScrubbedChildEnv } from './child-environment.js';
7
7
  import { ARTIFACT_NEXT_APPROVED_PACKAGES } from './manifest.js';
8
+ import { isLocalEnvironmentName, isLoopbackHostname } from './loopback.js';
8
9
  const packageSchema = z.object({
9
10
  dependencies: z.record(z.string(), z.string()).default({}),
10
11
  devDependencies: z.record(z.string(), z.string()).default({}),
@@ -42,7 +43,7 @@ export async function validateNextProject({ root, manifest, installed, nodeVersi
42
43
  }
43
44
  export function validateNextPublicOrigin({ value, environment }) {
44
45
  const url = new URL(value);
45
- const local = environment === 'local' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
46
+ const local = isLocalEnvironmentName(environment) && isLoopbackHostname(url.hostname);
46
47
  if ((url.protocol !== 'https:' && !(local && url.protocol === 'http:')) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
47
48
  throw new Error('Atlas returned an invalid application origin; an exact credential-free HTTPS origin is required.');
48
49
  }
package/dist/project.js CHANGED
@@ -9,7 +9,7 @@ import { readNextSourceSnapshot } from './next-source.js';
9
9
  import { normalizeArtifactPath } from './paths.js';
10
10
  const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.artifact-studio']);
11
11
  const TEXT_EXTENSIONS = new Set([
12
- '.ts', '.tsx', '.js', '.jsx', '.json', '.css', '.html', '.md', '.yml', '.yaml', '.svg',
12
+ '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json', '.css', '.html', '.md', '.yml', '.yaml', '.svg',
13
13
  ]);
14
14
  export function hasArtifactStudioManifest(rootDir) {
15
15
  return existsSync(join(resolve(rootDir), 'artifact.bundle.yml'));
@@ -83,9 +83,9 @@ function isTextFile(path) {
83
83
  TEXT_EXTENSIONS.has(ext));
84
84
  }
85
85
  function inferContentType(path) {
86
- if (path.endsWith('.tsx') || path.endsWith('.ts'))
86
+ if (/\.(?:tsx?|mts|cts)$/.test(path))
87
87
  return 'text/typescript';
88
- if (path.endsWith('.jsx') || path.endsWith('.js'))
88
+ if (/\.(?:jsx?|mjs|cjs)$/.test(path))
89
89
  return 'text/javascript';
90
90
  if (path.endsWith('.css'))
91
91
  return 'text/css';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Capabilities are code, roles are data: roles are created in Atlas, and
3
+ * `capabilities.roles.definitions` here is a deprecated one-time seed (a
4
+ * deploy creates any that don't exist yet and never updates them). Atlas only
5
+ * logs the drift, so tell the author.
6
+ */
7
+ export declare function warnOnRoleDefinitions(manifest: unknown, warn?: (line: string) => void): void;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Capabilities are code, roles are data: roles are created in Atlas, and
3
+ * `capabilities.roles.definitions` here is a deprecated one-time seed (a
4
+ * deploy creates any that don't exist yet and never updates them). Atlas only
5
+ * logs the drift, so tell the author.
6
+ */
7
+ export function warnOnRoleDefinitions(manifest, warn = console.warn) {
8
+ // A non-null, non-array object is safe to index by key; TS can't narrow `object` to a record itself.
9
+ const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
10
+ const definitions = record(record(record(record(manifest)?.capabilities)?.roles)?.definitions);
11
+ if (!definitions)
12
+ return;
13
+ const keys = Object.keys(definitions).sort();
14
+ if (keys.length === 0)
15
+ return;
16
+ warn(`[seq-studio] artifact.bundle.yml defines roles (${keys.join(', ')}). Roles in YAML are a deprecated one-time seed: ` +
17
+ `a deploy creates any that don't exist yet and never updates them after. Create and edit roles in Atlas ` +
18
+ `(Permissions → Roles); keep capabilities in YAML.`);
19
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { ARTIFACT_NEXT_RUNTIME, artifactNextSecretNameSchema } from './manifest-next.js';
3
3
  import { MAX_STUDIO_PATH_LENGTH } from './paths.js';
4
+ import { isLoopbackHostname } from './loopback.js';
4
5
  export { resolveArtifactNextImages } from './next-image-config.js';
5
6
  /** Shared by local collection, repository collection and the bounded source-upload API. */
6
7
  export const ARTIFACT_NEXT_SOURCE_LIMITS = Object.freeze({
@@ -42,7 +43,7 @@ export const artifactSourcePathSchema = z.string().min(1).max(MAX_STUDIO_PATH_LE
42
43
  export const artifactRuntimeOriginSchema = z.string().max(2048).refine((value) => {
43
44
  try {
44
45
  const url = new URL(value);
45
- return url.protocol === 'https:' && url.origin === value && !url.hostname.includes('*');
46
+ return (url.protocol === 'https:' || (url.protocol === 'http:' && isLoopbackHostname(url.hostname))) && url.origin === value && !url.hostname.includes('*');
46
47
  }
47
48
  catch {
48
49
  return false;
@@ -42,7 +42,7 @@ const RULES = [
42
42
  message: "Direct fetch() to an external origin is blocked (CSP connect-src 'self') — call Atlas routes via seq.api.fetch.",
43
43
  },
44
44
  ];
45
- const LINTABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.css', '.html']);
45
+ const LINTABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.css', '.html']);
46
46
  function extensionOf(path) {
47
47
  return path.includes('.') ? path.slice(path.lastIndexOf('.')) : '';
48
48
  }
@@ -0,0 +1,18 @@
1
+ import { type OperationExecutor } from '@sequenceholdings/orm/client';
2
+ import { type SequenceApi } from './sdk.js';
3
+ export interface ArtifactOrmExecutorConfig {
4
+ namespace: string;
5
+ /** The generated operations.manifest.json, validated before any host call. */
6
+ manifest: unknown;
7
+ /** Optional action-scoped host client (for example, withTraceAction's client). */
8
+ sdk?: {
9
+ api: Pick<SequenceApi, 'fetch'>;
10
+ };
11
+ }
12
+ /**
13
+ * Execute persisted operations as the signed-in Artifact user. The host owns
14
+ * authentication; this helper never obtains or exposes browser credentials.
15
+ * Declare the namespace path in capabilities.api.write (queries also use POST).
16
+ * Namespace grants, capabilities, row policies, and write checks still apply.
17
+ */
18
+ export declare function createArtifactOrmExecutor({ namespace, manifest, sdk }: ArtifactOrmExecutorConfig): OperationExecutor;
@@ -0,0 +1,21 @@
1
+ import { createOperationExecutorWithTransport, OrmClientError } from '@sequenceholdings/orm/client';
2
+ import { seq } from './sdk.js';
3
+ /**
4
+ * Execute persisted operations as the signed-in Artifact user. The host owns
5
+ * authentication; this helper never obtains or exposes browser credentials.
6
+ * Declare the namespace path in capabilities.api.write (queries also use POST).
7
+ * Namespace grants, capabilities, row policies, and write checks still apply.
8
+ */
9
+ export function createArtifactOrmExecutor({ namespace, manifest, sdk = seq }) {
10
+ if (!sdk || typeof sdk.api?.fetch !== 'function') {
11
+ throw new OrmClientError('Artifact ORM requires the authenticated Artifact Studio host; open the deployed artifact or its Preview');
12
+ }
13
+ return createOperationExecutorWithTransport({
14
+ namespace,
15
+ manifest,
16
+ transport: ({ namespace: target, op, variables }) => sdk.api.fetch(`/api/data/v2/${target}`, {
17
+ method: 'POST',
18
+ body: { op, variables },
19
+ }),
20
+ });
21
+ }
package/dist/sdk.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type StartStorageUpload, type StorageUploadSession } from '@sequenceholdings/storage';
2
+ export { createArtifactOrmExecutor, type ArtifactOrmExecutorConfig } from './sdk-orm.js';
2
3
  export interface SequenceStorageOptions {
3
4
  signal?: AbortSignal;
4
5
  timeoutMs?: number;
package/dist/sdk.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { storageClient } from '@sequenceholdings/storage';
2
+ export { createArtifactOrmExecutor } from './sdk-orm.js';
2
3
  export const seq = typeof window !== 'undefined'
3
4
  ? window.seq
4
5
  : undefined;
@@ -1,5 +1,5 @@
1
1
  import { storageClient } from '@sequenceholdings/storage';
2
- import { type OperationsManifest, type OperationExecutor } from '@sequenceholdings/orm/client';
2
+ import { type OperationExecutor } from '@sequenceholdings/orm/client';
3
3
  import type { SequenceApiFetchResponse } from '../sdk.js';
4
4
  import { type ArtifactServerMode, type ArtifactServerRequest } from './broker-contract.js';
5
5
  import { createArtifactLogger, type ArtifactLogRecord } from './logger.js';
@@ -15,6 +15,20 @@ export interface ArtifactServerFetchOptions {
15
15
  body?: unknown;
16
16
  signal?: AbortSignal;
17
17
  }
18
+ /**
19
+ * The namespace's generated operations.manifest.json. Only each operation's persisted hash
20
+ * is read, so the file can be passed as imported: a JSON import types `kind` as string.
21
+ */
22
+ export interface ArtifactOrmOperations {
23
+ readonly manifestVersion?: number;
24
+ readonly schemaHash?: string;
25
+ readonly opsHash?: string;
26
+ readonly operations: Readonly<Record<string, {
27
+ readonly hash: string;
28
+ readonly kind?: string;
29
+ readonly requires?: unknown;
30
+ }>>;
31
+ }
18
32
  export interface ArtifactServerClient {
19
33
  context: ArtifactRequestContext;
20
34
  mode: ArtifactServerMode;
@@ -26,7 +40,7 @@ export interface ArtifactServerClient {
26
40
  };
27
41
  orm(input: {
28
42
  namespace: string;
29
- manifest: OperationsManifest;
43
+ manifest: ArtifactOrmOperations;
30
44
  }): OperationExecutor;
31
45
  asService(): ArtifactServerClient;
32
46
  }
@@ -20,6 +20,7 @@ export declare const artifactRequestContextSchema: z.ZodObject<{
20
20
  deploymentId: z.ZodNullable<z.ZodString>;
21
21
  devSessionId: z.ZodNullable<z.ZodString>;
22
22
  requestId: z.ZodString;
23
+ jti: z.ZodUUID;
23
24
  method: z.ZodEnum<{
24
25
  GET: "GET";
25
26
  POST: "POST";
@@ -1,5 +1,6 @@
1
1
  import { createPublicKey, verify } from 'node:crypto';
2
2
  import { z } from 'zod';
3
+ import { isLoopbackHostname } from '../loopback.js';
3
4
  export const ARTIFACT_REQUEST_CONTEXT_HEADER = 'x-sequence-artifact-context';
4
5
  export const ARTIFACT_RUNTIME_TOKEN_HEADER = 'x-sequence-artifact-runtime';
5
6
  export const ARTIFACT_SERVER_REQUEST_PATH = '/api/artifact-studio/server/request';
@@ -9,7 +10,7 @@ const originSchema = z.string().max(2048).refine(value => {
9
10
  try {
10
11
  const url = new URL(value);
11
12
  return value === url.origin && (url.protocol === 'https:' ||
12
- (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)));
13
+ (url.protocol === 'http:' && isLoopbackHostname(url.hostname)));
13
14
  }
14
15
  catch {
15
16
  return false;
@@ -26,7 +27,11 @@ export const artifactRequestContextSchema = z.object({
26
27
  mode: z.enum(['development', 'preview', 'published']),
27
28
  deploymentId: id.nullable(),
28
29
  devSessionId: id.nullable(),
30
+ /** Correlates logs, usage and audit records for the originating request. */
29
31
  requestId: id,
32
+ /** Unique per signed context. Atlas budgets delegated calls per jti until the context expires. */
33
+ jti: z.uuid(),
34
+ /** The originating browser request's method. Atlas only delegates reads for GET, HEAD and OPTIONS. */
30
35
  method: z.enum(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']),
31
36
  path: z.string().min(1).max(8192).refine(value => value.startsWith('/') &&
32
37
  !value.startsWith('//') && !/[\\\r\n\u0000#]/.test(value), 'Expected an origin-relative request path'),
@@ -31,6 +31,7 @@ export declare function getArtifactRequestContext({ headers, runtime, method, pa
31
31
  deploymentId: string | null;
32
32
  devSessionId: string | null;
33
33
  requestId: string;
34
+ jti: string;
34
35
  method: "GET" | "POST" | "DELETE" | "HEAD" | "PUT" | "PATCH" | "OPTIONS";
35
36
  path: string;
36
37
  iat: number;
@@ -2,11 +2,12 @@ import { constants } from 'node:fs';
2
2
  import { open } from 'node:fs/promises';
3
3
  import { z } from 'zod';
4
4
  import { ARTIFACT_REQUEST_CONTEXT_HEADER, verifyArtifactRequestContext } from './request-context.js';
5
+ import { isLoopbackHostname } from '../loopback.js';
5
6
  const atlasOrigin = z.string().refine(value => {
6
7
  try {
7
8
  const url = new URL(value);
8
9
  return value === url.origin && (url.protocol === 'https:' ||
9
- (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)));
10
+ (url.protocol === 'http:' && isLoopbackHostname(url.hostname)));
10
11
  }
11
12
  catch {
12
13
  return false;
package/dist/server.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import 'server-only';
2
2
  export { createArtifactServerClient, ArtifactServerError } from './server/client.js';
3
- export type { ArtifactServerClient, ArtifactServerFetchOptions } from './server/client.js';
3
+ export type { ArtifactOrmOperations, ArtifactServerClient, ArtifactServerFetchOptions } from './server/client.js';
4
4
  export { getArtifactRequestContext, readArtifactRuntimeConfig } from './server/runtime-config.js';
5
5
  export type { ArtifactRuntimeConfig, ArtifactRequestHeaders } from './server/runtime-config.js';
6
6
  export { getArtifactBrowserBootstrap } from './server/request-context.js';
@@ -71,6 +71,12 @@ only the capabilities the application needs, then configure authorized resource
71
71
  bindings in Atlas. Local development uses explicit nonproduction resources and
72
72
  never falls back to production when a binding is missing.
73
73
 
74
+ ORM access is declared per table and per mutation: list `<namespace>/<table>`
75
+ under `data.read` for every table a query reads (queries that follow
76
+ platform-user relations also read `<namespace>/platformUsers`). Under
77
+ `data.write`, use `<namespace>/ops/<Operation>` for each persisted mutation the
78
+ app runs, or `<namespace>/<table>` for table-level coverage.
79
+
74
80
  Managed secrets stay in hosted runtimes. For local integration tests, use your
75
81
  own test credentials, mocks or an existing governed function. Use a hosted
76
82
  preview for application code that needs a managed secret. Never put secrets in
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ .next/
3
+ next-env.d.ts
4
+ .artifact/
5
+ .artifact-studio/
6
+ .env*
7
+ *.tsbuildinfo
8
+ coverage/
9
+ .DS_Store
@@ -2,7 +2,21 @@ packages:
2
2
  - '.'
3
3
 
4
4
  minimumReleaseAge: 10080
5
+ # The Sequence SDK chain is published from the Studio repo's audited release
6
+ # pipeline, so a new artifact-studio release must install without waiting out
7
+ # the seven-day quarantine. Scoped to the exact first-party packages a fresh
8
+ # scaffold resolves, not @sequenceholdings/*.
9
+ minimumReleaseAgeExclude:
10
+ - '@sequenceholdings/artifact-studio'
11
+ - '@sequenceholdings/atlas-ui'
12
+ - '@sequenceholdings/custom-roles-manifest'
13
+ - '@sequenceholdings/lattice-form-renderer'
14
+ - '@sequenceholdings/orm'
15
+ - '@sequenceholdings/storage'
5
16
  strictDepBuilds: true
6
17
  allowBuilds:
7
18
  '@tailwindcss/oxide': false
19
+ # Reached through the SDK's static builder and ORM tooling, never run by a
20
+ # Next.js app; its platform binary installs as an optional dependency.
21
+ esbuild: false
8
22
  sharp: false
@@ -22,7 +22,7 @@ This is a sandboxed iframe SPA that runs inside Atlas and talks to Atlas backend
22
22
 
23
23
  ## Prerequisite
24
24
 
25
- None — `seq-studio artifact init <dir>` works from any directory; no monorepo checkout is required. The template lists `@sequenceholdings/artifact-studio` and `@sequenceholdings/atlas-ui` in **devDependencies** for local editor/type-check tooling only. `seq-studio artifact build|deploy` bundles against the CLI's own pinned copies of `react`, `react-dom`, `@tanstack/react-query`, `react-router-dom`, `sonner`, and `@sequenceholdings/atlas-ui`, and resolves `@sequenceholdings/artifact-studio` to a virtual `window.seq` module. Remote deploys (`--repo` / `--git-url`) run `pnpm install --prod` for **app-specific** `dependencies` only (e.g. `recharts`, `reactflow`) from the artifact lockfile.
25
+ None — `seq-studio artifact init <dir>` works from any directory; no monorepo checkout is required. The template lists `@sequenceholdings/artifact-studio` and `@sequenceholdings/atlas-ui` in **devDependencies** for local editor/type-check tooling only. `seq-studio artifact build|deploy` bundles against the CLI's own pinned copies of `react`, `react-dom`, `@tanstack/react-query`, `react-router-dom`, `sonner`, and `@sequenceholdings/atlas-ui`, and resolves `@sequenceholdings/artifact-studio` to the CLI-pinned SDK, whose `seq` export uses the host-injected `window.seq`. The SDK also provides `createArtifactOrmExecutor({ namespace, manifest })` for persisted ORM operations; declare `/api/data/v2/<namespace>` under `capabilities.api.write`, including queries (execution uses POST). The helper handles the ORM envelope; do not pass its result to the CRM `unwrap()` helper. Remote deploys (`--repo` / `--git-url`) run `pnpm install --prod` for **app-specific** `dependencies` only (e.g. `recharts`, `reactflow`) from the artifact lockfile.
26
26
 
27
27
  ## Package manager
28
28
 
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "dependencies": {},
11
11
  "devDependencies": {
12
- "@sequenceholdings/artifact-studio": "^0.3.0",
12
+ "@sequenceholdings/artifact-studio": "^0.4.0",
13
13
  "@sequenceholdings/atlas-ui": "^3.1.1",
14
14
  "@tailwindcss/postcss": "^4.2.2",
15
15
  "@tailwindcss/vite": "^4.2.2",