@sequenceholdings/artifact-studio 0.3.1 → 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.
- package/README.md +37 -0
- package/dist/build.js +1 -0
- package/dist/cli.js +5 -1
- package/dist/dev-link.js +1 -1
- package/dist/gateway/server.d.ts +3 -0
- package/dist/gateway/server.js +2 -2
- package/dist/gateway/session.d.ts +14 -1
- package/dist/gateway/session.js +17 -3
- 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/next/protocol.js +2 -1
- package/dist/next-dev.js +2 -1
- package/dist/next-project.js +2 -1
- 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.js +2 -1
- package/dist/sandbox-lint.js +1 -1
- package/dist/sdk-orm.d.ts +18 -0
- package/dist/sdk-orm.js +21 -0
- package/dist/sdk.d.ts +1 -0
- package/dist/sdk.js +1 -0
- package/dist/server/request-context.js +2 -1
- package/dist/server/runtime-config.js +2 -1
- package/dist/templates/react-vite/AGENTS.md +1 -1
- package/dist/templates/react-vite/package.json +1 -1
- package/dist/tunnel/client.js +10 -3
- package/dist/tunnel/protocol.js +2 -1
- package/package.json +2 -2
- package/templates/react-vite/AGENTS.md +1 -1
- 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/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
|
@@ -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',
|
|
@@ -505,7 +506,10 @@ async function deployCommand(args) {
|
|
|
505
506
|
// the current branch and dirty working tree.
|
|
506
507
|
if (env.name !== 'local')
|
|
507
508
|
assertActiveDeployProvenance(source.provenance);
|
|
508
|
-
|
|
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)) {
|
|
509
513
|
return await buildNextCommand({ args, source, active: true });
|
|
510
514
|
}
|
|
511
515
|
// --skip-unchanged: hash the raw source (no build needed) and no-op when
|
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. */
|
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
|
@@ -136,7 +136,7 @@ export function createArtifactGateway(options) {
|
|
|
136
136
|
}
|
|
137
137
|
if (session.publicOrigin !== origin || session.bridge.parentOrigin !== parentOrigin)
|
|
138
138
|
throw new GatewayControlError(403);
|
|
139
|
-
response.setHeader('set-cookie', gatewaySessionCookies({ session, key: options.sessionKey, cookie: header(request, 'cookie') }));
|
|
139
|
+
response.setHeader('set-cookie', gatewaySessionCookies({ session, key: options.sessionKey, cookie: header(request, 'cookie'), sameSite: options.sessionCookieSameSite }));
|
|
140
140
|
json(response, 200, 'connected');
|
|
141
141
|
}
|
|
142
142
|
/** Tunnel credential validation, shared by connection admission and every lease check. */
|
|
@@ -319,7 +319,7 @@ export function createArtifactGateway(options) {
|
|
|
319
319
|
}
|
|
320
320
|
const renewed = renewGatewaySession({ session, authorizedExpiresAt: target.expiresAt });
|
|
321
321
|
if (renewed)
|
|
322
|
-
response.setHeader('set-cookie', gatewaySessionCookies({ session: renewed, key: options.sessionKey, cookie: header(request, 'cookie'), renewal: true }));
|
|
322
|
+
response.setHeader('set-cookie', gatewaySessionCookies({ session: renewed, key: options.sessionKey, cookie: header(request, 'cookie'), renewal: true, sameSite: options.sessionCookieSameSite }));
|
|
323
323
|
// Authorization precedes every response, including Next static assets and cached pages.
|
|
324
324
|
response.setHeader('content-security-policy', `frame-ancestors ${session.bridge.parentOrigin}`);
|
|
325
325
|
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;
|
package/dist/gateway/session.js
CHANGED
|
@@ -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;
|
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
|
/**
|
|
@@ -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(
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
package/dist/next/protocol.js
CHANGED
|
@@ -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
|
|
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
|
}
|
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
|
|
222
|
+
localPort: port, publicOrigin: lease.session.publicOrigin, signal, allowInsecureLoopback: isLocalEnvironmentName(environment),
|
|
222
223
|
});
|
|
223
224
|
attempt = 0;
|
|
224
225
|
onStatus('ready');
|
package/dist/next-project.js
CHANGED
|
@@ -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
|
|
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 (
|
|
86
|
+
if (/\.(?:tsx?|mts|cts)$/.test(path))
|
|
87
87
|
return 'text/typescript';
|
|
88
|
-
if (
|
|
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;
|
package/dist/sandbox-lint.js
CHANGED
|
@@ -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;
|
package/dist/sdk-orm.js
ADDED
|
@@ -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,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:' &&
|
|
13
|
+
(url.protocol === 'http:' && isLoopbackHostname(url.hostname)));
|
|
13
14
|
}
|
|
14
15
|
catch {
|
|
15
16
|
return false;
|
|
@@ -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:' &&
|
|
10
|
+
(url.protocol === 'http:' && isLoopbackHostname(url.hostname)));
|
|
10
11
|
}
|
|
11
12
|
catch {
|
|
12
13
|
return 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
|
|
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.
|
|
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",
|
package/dist/tunnel/client.js
CHANGED
|
@@ -3,14 +3,21 @@ import WebSocket from 'ws';
|
|
|
3
3
|
import { TunnelPeer, tunnelLimits } from './peer.js';
|
|
4
4
|
import { assertSafeRequest, normalizePublicOrigin, parseTunnelFrame, sanitizeRequestHeaders, sanitizeResponseHeaders, TUNNEL_PROTOCOL } from './protocol.js';
|
|
5
5
|
import { forwardWebSocketMessages, MessageBudget, permittedCloseCode, sendWebSocket, WebSocketMessage, writeChunk } from './io.js';
|
|
6
|
+
import { isLoopbackHostname } from '../loopback.js';
|
|
6
7
|
/** One connection/epoch only. The caller obtains a fresh lease to reconnect;
|
|
7
8
|
* no HTTP request or mutation is ever retained or replayed. */
|
|
8
9
|
export async function connectLocalTunnel(options) {
|
|
9
10
|
const endpoint = new URL(options.url);
|
|
10
|
-
const insecureLocal = endpoint.protocol === 'ws:' &&
|
|
11
|
+
const insecureLocal = endpoint.protocol === 'ws:' && isLoopbackHostname(endpoint.hostname) && options.allowInsecureLoopback;
|
|
11
12
|
if ((endpoint.protocol !== 'wss:' && !insecureLocal) || endpoint.username || endpoint.password || endpoint.hash) {
|
|
12
13
|
throw new Error('Tunnel endpoint must use authenticated WSS');
|
|
13
14
|
}
|
|
15
|
+
// A cleartext tunnel carries the bearer credential, so a localhost name is dialed at the loopback
|
|
16
|
+
// address browsers use for it, never through a resolver that could route it elsewhere. The Host
|
|
17
|
+
// header keeps the name the gateway validates.
|
|
18
|
+
const dial = new URL(endpoint);
|
|
19
|
+
if (insecureLocal && !['127.0.0.1', '[::1]'].includes(endpoint.hostname))
|
|
20
|
+
dial.hostname = '127.0.0.1';
|
|
14
21
|
if (!Number.isSafeInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)
|
|
15
22
|
throw new Error('Invalid loopback port');
|
|
16
23
|
if (!Number.isSafeInteger(options.epoch) || options.epoch < 1 || !options.sessionId || !options.token || /[\r\n]/.test(options.token))
|
|
@@ -18,8 +25,8 @@ export async function connectLocalTunnel(options) {
|
|
|
18
25
|
const publicOrigin = normalizePublicOrigin(options.publicOrigin);
|
|
19
26
|
const limits = tunnelLimits(options.limits);
|
|
20
27
|
options.signal?.throwIfAborted();
|
|
21
|
-
const socket = new WebSocket(
|
|
22
|
-
headers: { authorization: `Bearer ${options.token}`, 'x-artifact-session-id': options.sessionId, 'x-artifact-tunnel-epoch': String(options.epoch) },
|
|
28
|
+
const socket = new WebSocket(dial, [TUNNEL_PROTOCOL], {
|
|
29
|
+
headers: { host: endpoint.host, authorization: `Bearer ${options.token}`, 'x-artifact-session-id': options.sessionId, 'x-artifact-tunnel-epoch': String(options.epoch) },
|
|
23
30
|
maxPayload: limits.maxFrameBytes,
|
|
24
31
|
perMessageDeflate: false,
|
|
25
32
|
followRedirects: false,
|
package/dist/tunnel/protocol.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { isLoopbackHostname } from '../loopback.js';
|
|
3
4
|
/** Version 1 is negotiated by the tunnel connection, not by individual streams. */
|
|
4
5
|
export const TUNNEL_PROTOCOL = 'artifact-tunnel.v1';
|
|
5
6
|
export const DEFAULT_TUNNEL_LIMITS = Object.freeze({
|
|
@@ -127,7 +128,7 @@ export function encodeTunnelFrame(frame) {
|
|
|
127
128
|
}
|
|
128
129
|
export function normalizePublicOrigin(value) {
|
|
129
130
|
const url = new URL(value);
|
|
130
|
-
const isLoopback =
|
|
131
|
+
const isLoopback = isLoopbackHostname(url.hostname);
|
|
131
132
|
if (!/^https?:\/\/[^/?#@\\\s]+\/?$/i.test(value) || url.username || url.password || url.search || url.hash ||
|
|
132
133
|
url.pathname !== '/' || (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)))
|
|
133
134
|
throw new Error('Tunnel public origin must be an HTTPS origin or HTTP loopback origin');
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"publishConfig": {
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "0.4.0",
|
|
13
13
|
"description": "SDK types and CLI library for building Artifact Studio apps. Driven via `seq-studio artifact <sub>` (@sequenceholdings/studio-cli), which runs the `./cli` runCli export.",
|
|
14
14
|
"type": "module",
|
|
15
15
|
"exports": {
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
"parse5": "7.3.0",
|
|
149
149
|
"@sequenceholdings/lattice-form-renderer": "0.1.3",
|
|
150
150
|
"@sequenceholdings/storage": "0.1.0",
|
|
151
|
-
"@sequenceholdings/orm": "2.1.
|
|
151
|
+
"@sequenceholdings/orm": "2.1.1"
|
|
152
152
|
},
|
|
153
153
|
"devDependencies": {
|
|
154
154
|
"@types/js-yaml": "^4.0.9",
|
|
@@ -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
|
|
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.
|
|
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",
|