@selvajs/server 0.2.0-beta.2 → 0.2.0-beta.4
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/LICENSE +1 -1
- package/README.md +32 -0
- package/dist/compute/client-cache.d.ts +12 -0
- package/dist/compute/client-cache.d.ts.map +1 -1
- package/dist/compute/client-cache.js +18 -3
- package/dist/compute/client-cache.js.map +1 -1
- package/dist/compute/definition-byte-cache.d.ts +85 -0
- package/dist/compute/definition-byte-cache.d.ts.map +1 -0
- package/dist/compute/definition-byte-cache.js +110 -0
- package/dist/compute/definition-byte-cache.js.map +1 -0
- package/dist/compute/index.d.ts +7 -2
- package/dist/compute/index.d.ts.map +1 -1
- package/dist/compute/index.js +6 -1
- package/dist/compute/index.js.map +1 -1
- package/dist/compute/limits.d.ts +52 -0
- package/dist/compute/limits.d.ts.map +1 -1
- package/dist/compute/limits.js +33 -1
- package/dist/compute/limits.js.map +1 -1
- package/dist/compute/memory-solve-cache.d.ts +58 -0
- package/dist/compute/memory-solve-cache.d.ts.map +1 -0
- package/dist/compute/memory-solve-cache.js +0 -0
- package/dist/compute/memory-solve-cache.js.map +1 -0
- package/dist/compute/safe-url.d.ts.map +1 -1
- package/dist/compute/safe-url.js +16 -0
- package/dist/compute/safe-url.js.map +1 -1
- package/dist/compute/solve-cache-envelope.d.ts +53 -0
- package/dist/compute/solve-cache-envelope.d.ts.map +1 -0
- package/dist/compute/solve-cache-envelope.js +66 -0
- package/dist/compute/solve-cache-envelope.js.map +1 -0
- package/dist/compute/solve-cache-key.d.ts +65 -0
- package/dist/compute/solve-cache-key.d.ts.map +1 -0
- package/dist/compute/solve-cache-key.js +50 -0
- package/dist/compute/solve-cache-key.js.map +1 -0
- package/dist/compute/solve-cache-single-flight.d.ts +42 -0
- package/dist/compute/solve-cache-single-flight.d.ts.map +1 -0
- package/dist/compute/solve-cache-single-flight.js +50 -0
- package/dist/compute/solve-cache-single-flight.js.map +1 -0
- package/dist/compute/solve-pipeline.d.ts +71 -8
- package/dist/compute/solve-pipeline.d.ts.map +1 -1
- package/dist/compute/solve-pipeline.js +180 -8
- package/dist/compute/solve-pipeline.js.map +1 -1
- package/dist/definitions/definition-service.d.ts +1 -1
- package/dist/definitions/definition-service.d.ts.map +1 -1
- package/dist/definitions/definition-service.js +8 -4
- package/dist/definitions/definition-service.js.map +1 -1
- package/package.json +14 -18
- package/dist/access/__tests__/project-access-input.test.d.ts +0 -2
- package/dist/access/__tests__/project-access-input.test.d.ts.map +0 -1
- package/dist/access/__tests__/project-access-input.test.js +0 -146
- package/dist/access/__tests__/project-access-input.test.js.map +0 -1
- package/dist/compute/__tests__/client-cache.test.d.ts +0 -11
- package/dist/compute/__tests__/client-cache.test.d.ts.map +0 -1
- package/dist/compute/__tests__/client-cache.test.js +0 -200
- package/dist/compute/__tests__/client-cache.test.js.map +0 -1
- package/dist/compute/__tests__/rate-limit.test.d.ts +0 -2
- package/dist/compute/__tests__/rate-limit.test.d.ts.map +0 -1
- package/dist/compute/__tests__/rate-limit.test.js +0 -98
- package/dist/compute/__tests__/rate-limit.test.js.map +0 -1
- package/dist/compute/__tests__/remote-definition.test.d.ts +0 -11
- package/dist/compute/__tests__/remote-definition.test.d.ts.map +0 -1
- package/dist/compute/__tests__/remote-definition.test.js +0 -114
- package/dist/compute/__tests__/remote-definition.test.js.map +0 -1
- package/dist/compute/__tests__/safe-url.test.d.ts +0 -2
- package/dist/compute/__tests__/safe-url.test.d.ts.map +0 -1
- package/dist/compute/__tests__/safe-url.test.js +0 -71
- package/dist/compute/__tests__/safe-url.test.js.map +0 -1
- package/dist/compute/__tests__/solve-pipeline.test.d.ts +0 -11
- package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +0 -1
- package/dist/compute/__tests__/solve-pipeline.test.js +0 -292
- package/dist/compute/__tests__/solve-pipeline.test.js.map +0 -1
- package/dist/compute/__tests__/transform-input.test.d.ts +0 -14
- package/dist/compute/__tests__/transform-input.test.d.ts.map +0 -1
- package/dist/compute/__tests__/transform-input.test.js +0 -120
- package/dist/compute/__tests__/transform-input.test.js.map +0 -1
- package/dist/definitions/__tests__/load-for-render.test.d.ts +0 -8
- package/dist/definitions/__tests__/load-for-render.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/load-for-render.test.js +0 -196
- package/dist/definitions/__tests__/load-for-render.test.js.map +0 -1
- package/dist/definitions/__tests__/schema-extraction.test.d.ts +0 -11
- package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +0 -1
- package/dist/definitions/__tests__/schema-extraction.test.js +0 -115
- package/dist/definitions/__tests__/schema-extraction.test.js.map +0 -1
- package/dist/http/__tests__/body-size.test.d.ts +0 -2
- package/dist/http/__tests__/body-size.test.d.ts.map +0 -1
- package/dist/http/__tests__/body-size.test.js +0 -24
- package/dist/http/__tests__/body-size.test.js.map +0 -1
- package/dist/http/__tests__/route-classifier.test.d.ts +0 -2
- package/dist/http/__tests__/route-classifier.test.d.ts.map +0 -1
- package/dist/http/__tests__/route-classifier.test.js +0 -54
- package/dist/http/__tests__/route-classifier.test.js.map +0 -1
- package/dist/http/__tests__/safe-redirect.test.d.ts +0 -2
- package/dist/http/__tests__/safe-redirect.test.d.ts.map +0 -1
- package/dist/http/__tests__/safe-redirect.test.js +0 -44
- package/dist/http/__tests__/safe-redirect.test.js.map +0 -1
- package/dist/http/__tests__/security-headers.test.d.ts +0 -2
- package/dist/http/__tests__/security-headers.test.d.ts.map +0 -1
- package/dist/http/__tests__/security-headers.test.js +0 -24
- package/dist/http/__tests__/security-headers.test.js.map +0 -1
- package/dist/ops/__tests__/semver.test.d.ts +0 -2
- package/dist/ops/__tests__/semver.test.d.ts.map +0 -1
- package/dist/ops/__tests__/semver.test.js +0 -38
- package/dist/ops/__tests__/semver.test.js.map +0 -1
- package/dist/providers/__tests__/create-selva-providers.test.d.ts +0 -7
- package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +0 -1
- package/dist/providers/__tests__/create-selva-providers.test.js +0 -135
- package/dist/providers/__tests__/create-selva-providers.test.js.map +0 -1
- package/dist/tokens/__tests__/token-codec.test.d.ts +0 -2
- package/dist/tokens/__tests__/token-codec.test.d.ts.map +0 -1
- package/dist/tokens/__tests__/token-codec.test.js +0 -50
- package/dist/tokens/__tests__/token-codec.test.js.map +0 -1
- package/src/access/__tests__/project-access-input.test.ts +0 -175
- package/src/access/index.ts +0 -9
- package/src/access/project-access-input.ts +0 -145
- package/src/compute/__tests__/client-cache.test.ts +0 -224
- package/src/compute/__tests__/rate-limit.test.ts +0 -106
- package/src/compute/__tests__/remote-definition.test.ts +0 -140
- package/src/compute/__tests__/safe-url.test.ts +0 -72
- package/src/compute/__tests__/solve-pipeline.test.ts +0 -341
- package/src/compute/__tests__/transform-input.test.ts +0 -162
- package/src/compute/client-cache.ts +0 -300
- package/src/compute/index.ts +0 -48
- package/src/compute/limits.ts +0 -159
- package/src/compute/rate-limit.ts +0 -110
- package/src/compute/remote-definition.ts +0 -149
- package/src/compute/safe-url.ts +0 -186
- package/src/compute/solve-pipeline.ts +0 -339
- package/src/compute/transform-input.ts +0 -66
- package/src/definitions/__tests__/load-for-render.test.ts +0 -244
- package/src/definitions/__tests__/schema-extraction.test.ts +0 -137
- package/src/definitions/definition-service.ts +0 -243
- package/src/definitions/index.ts +0 -22
- package/src/definitions/load-for-render.ts +0 -272
- package/src/definitions/schema-extraction.ts +0 -117
- package/src/errors/SentryErrorReporter.ts +0 -88
- package/src/errors/index.ts +0 -3
- package/src/http/__tests__/body-size.test.ts +0 -27
- package/src/http/__tests__/route-classifier.test.ts +0 -60
- package/src/http/__tests__/safe-redirect.test.ts +0 -47
- package/src/http/__tests__/security-headers.test.ts +0 -29
- package/src/http/body-size.ts +0 -24
- package/src/http/index.ts +0 -10
- package/src/http/redirect.ts +0 -16
- package/src/http/route-classifier.ts +0 -74
- package/src/http/security-headers.ts +0 -42
- package/src/index.ts +0 -27
- package/src/ops/__tests__/semver.test.ts +0 -42
- package/src/ops/index.ts +0 -3
- package/src/ops/semver.ts +0 -60
- package/src/providers/__tests__/create-selva-providers.test.ts +0 -172
- package/src/providers/__tests__/fixtures/override-config.mjs +0 -8
- package/src/providers/create-selva-providers.ts +0 -241
- package/src/providers/index.ts +0 -12
- package/src/tokens/__tests__/token-codec.test.ts +0 -63
- package/src/tokens/index.ts +0 -8
- package/src/tokens/token-codec.ts +0 -86
package/src/definitions/index.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
// Definitions server slice — the write orchestration (DefinitionService), the
|
|
2
|
-
// compute-schema extraction/validation gate, and the render loader. All
|
|
3
|
-
// composition-root wiring (stores, warm-client cache, server resolution) is
|
|
4
|
-
// injected; access gating stays with the calling app.
|
|
5
|
-
|
|
6
|
-
export { DefinitionService, type CreateDefinitionRecord } from './definition-service.js';
|
|
7
|
-
|
|
8
|
-
export {
|
|
9
|
-
fetchSchemaFromCompute,
|
|
10
|
-
assertSupportedSchemaVersion,
|
|
11
|
-
SchemaExtractionError
|
|
12
|
-
} from './schema-extraction.js';
|
|
13
|
-
|
|
14
|
-
export {
|
|
15
|
-
createDefinitionLoader,
|
|
16
|
-
DefinitionLoadError,
|
|
17
|
-
type DefinitionChannel,
|
|
18
|
-
type DefinitionLoader,
|
|
19
|
-
type DefinitionLoaderDeps,
|
|
20
|
-
type DefinitionLoadErrorKind,
|
|
21
|
-
type LoadedDefinition
|
|
22
|
-
} from './load-for-render.js';
|
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
// Loader for "given a definition record, produce everything the client needs to
|
|
2
|
-
// render its form and solve it": version resolution, blob fetch, compute-server
|
|
3
|
-
// resolution, schema fetch (with the ADR 0005 staleness check), default merging.
|
|
4
|
-
//
|
|
5
|
-
// Access gating is intentionally NOT done here — the calling route decides
|
|
6
|
-
// whether `ctx` is allowed to load this definition, since gates differ across
|
|
7
|
-
// routes (project membership, segment ownership, share-token, admin override).
|
|
8
|
-
//
|
|
9
|
-
// Everything stateful is injected (`DefinitionLoaderDeps`): stores, server
|
|
10
|
-
// resolution, the warm-client cache, and the schema fetcher — so any app built
|
|
11
|
-
// on the engine can wire its own composition root.
|
|
12
|
-
|
|
13
|
-
import type { GrasshopperClient } from '@selvajs/compute/grasshopper';
|
|
14
|
-
import { UI_SCHEMA_VERSION, type UISchema } from '@selvajs/schemas';
|
|
15
|
-
import type {
|
|
16
|
-
ComputeServerConfig,
|
|
17
|
-
DefinitionRecord,
|
|
18
|
-
DefinitionVersion,
|
|
19
|
-
IDefinitionStore,
|
|
20
|
-
IProjectStore,
|
|
21
|
-
IStorageProvider,
|
|
22
|
-
RequestContext
|
|
23
|
-
} from '@selvajs/platform';
|
|
24
|
-
import { fetchSchemaFromCompute } from './schema-extraction.js';
|
|
25
|
-
|
|
26
|
-
export type DefinitionChannel = 'live' | 'draft';
|
|
27
|
-
|
|
28
|
-
export interface LoadedDefinition {
|
|
29
|
-
version: DefinitionVersion;
|
|
30
|
-
definitionSource: Uint8Array;
|
|
31
|
-
computeServer: ComputeServerConfig;
|
|
32
|
-
schema: UISchema;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Classified failure modes so callers can map to appropriate HTTP status:
|
|
37
|
-
* - 'data': record references a version/blob that isn't there. (400)
|
|
38
|
-
* - 'missing-config': no compute server resolvable for this org/definition. (503)
|
|
39
|
-
* - 'connect': compute server is configured but unreachable. (503)
|
|
40
|
-
* - 'schema': compute responded but IO/schema phase failed. (500)
|
|
41
|
-
*/
|
|
42
|
-
export type DefinitionLoadErrorKind = 'data' | 'missing-config' | 'connect' | 'schema';
|
|
43
|
-
|
|
44
|
-
export class DefinitionLoadError extends Error {
|
|
45
|
-
constructor(
|
|
46
|
-
public readonly kind: DefinitionLoadErrorKind,
|
|
47
|
-
message: string,
|
|
48
|
-
public readonly cause?: unknown
|
|
49
|
-
) {
|
|
50
|
-
super(message);
|
|
51
|
-
this.name = 'DefinitionLoadError';
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface DefinitionLoaderDeps {
|
|
56
|
-
storage: Pick<IStorageProvider, 'get'>;
|
|
57
|
-
definitions: Pick<IDefinitionStore, 'getVersion' | 'setVersionSchema'>;
|
|
58
|
-
projects: Pick<IProjectStore, 'getProject'>;
|
|
59
|
-
/** §3 resolution order: definition pin → org default override → global default. */
|
|
60
|
-
resolveServer: (
|
|
61
|
-
ctx: RequestContext,
|
|
62
|
-
orgId: string | null,
|
|
63
|
-
opts: { definitionPin: string | null }
|
|
64
|
-
) => Promise<ComputeServerConfig>;
|
|
65
|
-
/** Shared warm-client cache (ADR 0004: keyed by server id, definition-guid affinity header). */
|
|
66
|
-
getClient: (
|
|
67
|
-
server: ComputeServerConfig,
|
|
68
|
-
opts: { definitionGuid: string }
|
|
69
|
-
) => Promise<{ client: GrasshopperClient }>;
|
|
70
|
-
/** Schema extraction; defaults to `fetchSchemaFromCompute`. Injectable for tests. */
|
|
71
|
-
fetchSchema?: (bytes: Uint8Array, server: ComputeServerConfig) => Promise<UISchema>;
|
|
72
|
-
/**
|
|
73
|
-
* Verbose IO diagnostics — when set, an unhealthy IO result (empty inputs /
|
|
74
|
-
* parse / load errors) also dumps the raw /io wire shape so
|
|
75
|
-
* casing/serialization mismatches are visible.
|
|
76
|
-
*/
|
|
77
|
-
computeDebug?: boolean;
|
|
78
|
-
/** Diagnostic sink; defaults to `console.warn`. */
|
|
79
|
-
onWarn?: (message: string, detail?: string) => void;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export type DefinitionLoader = (
|
|
83
|
-
ctx: RequestContext,
|
|
84
|
-
record: DefinitionRecord,
|
|
85
|
-
channel: DefinitionChannel,
|
|
86
|
-
explicitVersionId?: string | null
|
|
87
|
-
) => Promise<LoadedDefinition>;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Build a definition loader over the injected stores/compute wiring.
|
|
91
|
-
*
|
|
92
|
-
* The returned function loads a definition's version blob + schema (with
|
|
93
|
-
* merged compute defaults). By default it resolves the `channel` pointer
|
|
94
|
-
* (live/draft); pass `explicitVersionId` to render an arbitrary historical
|
|
95
|
-
* version instead — the caller must gate that behind edit permission
|
|
96
|
-
* (editor-only, like the draft channel). Throws `DefinitionLoadError` for
|
|
97
|
-
* classified failures so the caller can translate to HTTP status codes.
|
|
98
|
-
*/
|
|
99
|
-
export function createDefinitionLoader(deps: DefinitionLoaderDeps): DefinitionLoader {
|
|
100
|
-
const {
|
|
101
|
-
storage,
|
|
102
|
-
definitions,
|
|
103
|
-
projects,
|
|
104
|
-
resolveServer,
|
|
105
|
-
getClient,
|
|
106
|
-
fetchSchema = fetchSchemaFromCompute,
|
|
107
|
-
computeDebug = false,
|
|
108
|
-
onWarn = (message, detail) => (detail ? console.warn(message, detail) : console.warn(message))
|
|
109
|
-
} = deps;
|
|
110
|
-
|
|
111
|
-
return async function loadDefinitionForRender(ctx, record, channel, explicitVersionId) {
|
|
112
|
-
const versionId =
|
|
113
|
-
explicitVersionId ?? (channel === 'draft' ? record.draftVersionId : record.liveVersionId);
|
|
114
|
-
if (!versionId)
|
|
115
|
-
throw new DefinitionLoadError(
|
|
116
|
-
'data',
|
|
117
|
-
`Definition '${record.guid}' has no ${channel} version`
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
const version = await definitions.getVersion(ctx, versionId);
|
|
121
|
-
if (!version) throw new DefinitionLoadError('data', `version missing for '${record.guid}'`);
|
|
122
|
-
// An explicitly requested version must belong to this definition.
|
|
123
|
-
if (version.definitionId !== record.guid)
|
|
124
|
-
throw new DefinitionLoadError('data', `version does not belong to '${record.guid}'`);
|
|
125
|
-
|
|
126
|
-
const definitionSource = await storage.get(version.fileKey);
|
|
127
|
-
if (!definitionSource)
|
|
128
|
-
throw new DefinitionLoadError(
|
|
129
|
-
'data',
|
|
130
|
-
`Definition file for '${record.guid}' not found on disk`
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
const project = await projects.getProject(ctx, record.projectId);
|
|
134
|
-
let computeServer: ComputeServerConfig;
|
|
135
|
-
try {
|
|
136
|
-
computeServer = await resolveServer(ctx, project?.orgId ?? null, {
|
|
137
|
-
definitionPin: record.computeServerId ?? null
|
|
138
|
-
});
|
|
139
|
-
} catch (err) {
|
|
140
|
-
throw new DefinitionLoadError(
|
|
141
|
-
'missing-config',
|
|
142
|
-
'No compute server configured for this definition',
|
|
143
|
-
err
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Only the client is used here (getIO); the entry's solve scheduler rides
|
|
148
|
-
// along unused.
|
|
149
|
-
let client: GrasshopperClient;
|
|
150
|
-
try {
|
|
151
|
-
client = (await getClient(computeServer, { definitionGuid: record.guid })).client;
|
|
152
|
-
} catch (err) {
|
|
153
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
154
|
-
throw new DefinitionLoadError(
|
|
155
|
-
'connect',
|
|
156
|
-
`Failed to connect to Rhino Compute server: ${message}`,
|
|
157
|
-
err
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
try {
|
|
162
|
-
// Prefer the schema cached on the version row (extracted + validated at
|
|
163
|
-
// upload) — but ONLY when its format version matches the app's. The
|
|
164
|
-
// stored schema is a disposable cache (ADR 0005): on a version mismatch
|
|
165
|
-
// (or a pre-caching row with no schema at all) re-extract from compute,
|
|
166
|
-
// whose C# SchemaMigrator emits the current format — the web side never
|
|
167
|
-
// migrates schemas itself. `getIO` is always needed to merge compute
|
|
168
|
-
// default values into the schema, cached or not.
|
|
169
|
-
const cachedSchema = version.schema as UISchema | undefined;
|
|
170
|
-
const freshCache =
|
|
171
|
-
cachedSchema && cachedSchema.schemaVersion === UI_SCHEMA_VERSION ? cachedSchema : null;
|
|
172
|
-
const [definition, fetchedSchema] = await Promise.all([
|
|
173
|
-
client.getIO(definitionSource),
|
|
174
|
-
freshCache ? Promise.resolve(freshCache) : fetchSchema(definitionSource, computeServer)
|
|
175
|
-
]);
|
|
176
|
-
|
|
177
|
-
// Persist the refreshed schema back onto the version row (best-effort —
|
|
178
|
-
// a viewer context may lack write access; the next editor render or the
|
|
179
|
-
// solve-time backfill will land it). Only when the re-extracted schema
|
|
180
|
-
// is at the app's current version: persisting an older format (compute
|
|
181
|
-
// plugin behind the app) would just go stale again next render.
|
|
182
|
-
if (!freshCache && fetchedSchema.schemaVersion === UI_SCHEMA_VERSION) {
|
|
183
|
-
definitions.setVersionSchema(ctx, version.id, fetchedSchema).catch((err) => {
|
|
184
|
-
onWarn(
|
|
185
|
-
`[loadForRender] schema cache refresh failed for version ${version.id}: ` +
|
|
186
|
-
`${err instanceof Error ? err.message : String(err)}`
|
|
187
|
-
);
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (!definition) {
|
|
192
|
-
throw new Error(`Failed to get definition IO — server returned undefined`);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Surface IO health when something looks off: zero inputs, or the compute
|
|
196
|
-
// layer reported parse/load problems (e.g. an empty ValueList because its
|
|
197
|
-
// backing GH component didn't resolve → "Missing Definition Objects", or a
|
|
198
|
-
// PascalCase /io response the parser silently dropped to []). On a problem
|
|
199
|
-
// we also fetch the raw IO so the un-parsed wire shape is in the logs.
|
|
200
|
-
// Always-on warn (cheap, only fires on the unhappy path); the verbose raw
|
|
201
|
-
// dump is gated on `computeDebug`.
|
|
202
|
-
const ioParseErrors = (definition as { parseErrors?: unknown[] }).parseErrors ?? [];
|
|
203
|
-
const ioLoadErrors = (definition as { loadErrors?: unknown[] }).loadErrors ?? [];
|
|
204
|
-
const ioLooksBroken =
|
|
205
|
-
definition.inputs.length === 0 || ioParseErrors.length > 0 || ioLoadErrors.length > 0;
|
|
206
|
-
if (ioLooksBroken) {
|
|
207
|
-
onWarn(
|
|
208
|
-
`[loadForRender] IO health for '${record.guid}':`,
|
|
209
|
-
JSON.stringify({
|
|
210
|
-
inputCount: definition.inputs.length,
|
|
211
|
-
outputCount: definition.outputs?.length,
|
|
212
|
-
parseErrors: ioParseErrors,
|
|
213
|
-
loadErrors: ioLoadErrors,
|
|
214
|
-
loadWarnings: (definition as { loadWarnings?: unknown }).loadWarnings
|
|
215
|
-
})
|
|
216
|
-
);
|
|
217
|
-
if (computeDebug) {
|
|
218
|
-
const rawIO = await client
|
|
219
|
-
.getRawIO(definitionSource)
|
|
220
|
-
.catch((e) => ({ error: String(e) }));
|
|
221
|
-
onWarn('[loadForRender] raw /io:', JSON.stringify(rawIO).slice(0, 3000));
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return {
|
|
226
|
-
version,
|
|
227
|
-
definitionSource,
|
|
228
|
-
computeServer,
|
|
229
|
-
schema: mergeComputeDefaults(fetchedSchema, definition.inputs)
|
|
230
|
-
};
|
|
231
|
-
} catch (err) {
|
|
232
|
-
if (err instanceof DefinitionLoadError) throw err;
|
|
233
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
234
|
-
throw new DefinitionLoadError('schema', message, err);
|
|
235
|
-
}
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Merge default values from Compute definition into schema inputs.
|
|
240
|
-
// Colors come back as "r,g,b" or "a,r,g,b" strings — convert to hex so the
|
|
241
|
-
// color picker can consume them directly.
|
|
242
|
-
function mergeComputeDefaults(
|
|
243
|
-
schema: UISchema,
|
|
244
|
-
computeInputs: Array<{ id?: string; paramType?: string; default?: unknown }>
|
|
245
|
-
): UISchema {
|
|
246
|
-
const byParamId = new Map(computeInputs.map((input) => [input.id, input]));
|
|
247
|
-
|
|
248
|
-
return {
|
|
249
|
-
...schema,
|
|
250
|
-
inputs: schema.inputs.map((schemaInput) => {
|
|
251
|
-
const computeInput = byParamId.get(schemaInput.id);
|
|
252
|
-
|
|
253
|
-
if (computeInput?.paramType === 'Color' && computeInput.default !== undefined) {
|
|
254
|
-
const toHex = (value: number) => value.toString(16).padStart(2, '0');
|
|
255
|
-
const parts = String(computeInput.default)
|
|
256
|
-
.split(',')
|
|
257
|
-
.map((s) => parseInt(s.trim(), 10));
|
|
258
|
-
|
|
259
|
-
if (parts.length === 3) {
|
|
260
|
-
computeInput.default = `#${toHex(parts[0])}${toHex(parts[1])}${toHex(parts[2])}`;
|
|
261
|
-
} else if (parts.length === 4) {
|
|
262
|
-
computeInput.default = `#${toHex(parts[1])}${toHex(parts[2])}${toHex(parts[3])}`;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (computeInput && computeInput.default !== undefined) {
|
|
267
|
-
return { ...schemaInput, default: computeInput.default };
|
|
268
|
-
}
|
|
269
|
-
return schemaInput;
|
|
270
|
-
})
|
|
271
|
-
};
|
|
272
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
// Shared compute-schema extraction. One implementation used by:
|
|
2
|
-
// - upload routes (validate-and-cache gate — no upload without a valid schema)
|
|
3
|
-
// - the render path (falls back to live extraction for un-cached or stale
|
|
4
|
-
// versions; see load-for-render.ts and ADR 0005)
|
|
5
|
-
// - the temporary solve-time backfill bridge (see the app's specs/SchemaCaching.md)
|
|
6
|
-
//
|
|
7
|
-
// Extraction IS validation: a `.gh` with no valid "Schema" output, or an
|
|
8
|
-
// unreachable compute server, throws here and the caller rejects the upload.
|
|
9
|
-
|
|
10
|
-
import { camelcaseKeys } from '@selvajs/compute/core';
|
|
11
|
-
import { UI_SCHEMA_VERSION, type UISchema } from '@selvajs/schemas';
|
|
12
|
-
import type { ComputeServerConfig } from '@selvajs/platform';
|
|
13
|
-
|
|
14
|
-
/** Thrown when compute is reachable but the definition yields no usable schema. */
|
|
15
|
-
export class SchemaExtractionError extends Error {
|
|
16
|
-
constructor(
|
|
17
|
-
public readonly kind: 'unreachable' | 'invalid' | 'unsupported',
|
|
18
|
-
message: string
|
|
19
|
-
) {
|
|
20
|
-
super(message);
|
|
21
|
-
this.name = 'SchemaExtractionError';
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function parseSemver(version: string): [number, number, number] | null {
|
|
26
|
-
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
27
|
-
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Plugin↔app compat gate: reject a schema emitted by a NEWER plugin than this
|
|
32
|
-
* app was built against — its shape may contain constructs the renderer does
|
|
33
|
-
* not know. Older (or missing) versions pass: the compute-side C# migrator
|
|
34
|
-
* always emits its own current version, and an older shape only lacks
|
|
35
|
-
* optional additions.
|
|
36
|
-
*/
|
|
37
|
-
export function assertSupportedSchemaVersion(schema: UISchema): void {
|
|
38
|
-
const version = schema.schemaVersion;
|
|
39
|
-
if (!version) return; // pre-2.12.0 plugin — older than the app, fine
|
|
40
|
-
const extracted = parseSemver(version);
|
|
41
|
-
const supported = parseSemver(UI_SCHEMA_VERSION);
|
|
42
|
-
if (!extracted || !supported) return;
|
|
43
|
-
const newer =
|
|
44
|
-
extracted[0] > supported[0] ||
|
|
45
|
-
(extracted[0] === supported[0] &&
|
|
46
|
-
(extracted[1] > supported[1] ||
|
|
47
|
-
(extracted[1] === supported[1] && extracted[2] > supported[2])));
|
|
48
|
-
if (newer) {
|
|
49
|
-
throw new SchemaExtractionError(
|
|
50
|
-
'unsupported',
|
|
51
|
-
`This definition was authored with a newer Selva plugin (schema format ${version}); ` +
|
|
52
|
-
`this server supports schema format ≤ ${UI_SCHEMA_VERSION}. Update the server, or ` +
|
|
53
|
-
`re-save the definition with a matching plugin version.`
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Fetch the UI schema from Rhino Compute's `/grasshopper/schema` endpoint (no
|
|
60
|
-
* solve required). Throws `SchemaExtractionError` so callers can map to the
|
|
61
|
-
* right HTTP status (503 unreachable / 422 invalid or unsupported).
|
|
62
|
-
*/
|
|
63
|
-
export async function fetchSchemaFromCompute(
|
|
64
|
-
definitionBytes: Uint8Array,
|
|
65
|
-
server: ComputeServerConfig
|
|
66
|
-
): Promise<UISchema> {
|
|
67
|
-
const schemaUrl = new URL('/grasshopper/schema', server.serverUrl).toString();
|
|
68
|
-
|
|
69
|
-
const formData = new FormData();
|
|
70
|
-
const blob = new Blob([new Uint8Array(definitionBytes)], { type: 'application/octet-stream' });
|
|
71
|
-
formData.append('file', blob, 'definition.gh');
|
|
72
|
-
|
|
73
|
-
const headers: Record<string, string> = {};
|
|
74
|
-
if (server.apiKey) {
|
|
75
|
-
headers['RhinoComputeKey'] = server.apiKey;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let response: Response;
|
|
79
|
-
try {
|
|
80
|
-
response = await fetch(schemaUrl, { method: 'POST', headers, body: formData });
|
|
81
|
-
} catch (err) {
|
|
82
|
-
throw new SchemaExtractionError(
|
|
83
|
-
'unreachable',
|
|
84
|
-
`Compute server is unreachable: ${err instanceof Error ? err.message : String(err)}`
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (!response.ok) {
|
|
89
|
-
throw new SchemaExtractionError(
|
|
90
|
-
'unreachable',
|
|
91
|
-
`Schema endpoint returned ${response.status}: ${response.statusText}`
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Compute returns [{ FileName, Schemas }] with PascalCase wrapper keys only.
|
|
96
|
-
// The schema contents are already camelCase from our C# serializer, so we only
|
|
97
|
-
// need a shallow camelcase per wrapper to normalize FileName→fileName,
|
|
98
|
-
// Schemas→schemas (shallow camelcaseKeys is a no-op on arrays, so each
|
|
99
|
-
// element is normalized individually). deep:true would mangle user-defined
|
|
100
|
-
// option names (e.g. "Display3d" → "display3d").
|
|
101
|
-
const raw = await response.json();
|
|
102
|
-
const results = (Array.isArray(raw) ? raw : [raw]).map((r) => camelcaseKeys(r)) as {
|
|
103
|
-
schemas: UISchema[];
|
|
104
|
-
}[];
|
|
105
|
-
const schemas = results.flatMap((r) => r.schemas ?? []);
|
|
106
|
-
|
|
107
|
-
if (schemas.length === 0) {
|
|
108
|
-
throw new SchemaExtractionError(
|
|
109
|
-
'invalid',
|
|
110
|
-
'No schemas found in definition.\n\n' +
|
|
111
|
-
'In Grasshopper, verify a Context Bake component with the output name "Schema" is present and wired to the solver.'
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
assertSupportedSchemaVersion(schemas[0]);
|
|
116
|
-
return schemas[0];
|
|
117
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import type { ErrorContext, IErrorReporter } from '@selvajs/platform';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* `IErrorReporter` backed by Sentry (`@sentry/node`). Constructed only when
|
|
5
|
-
* the consuming app has a DSN configured. Deployments that don't configure a
|
|
6
|
-
* DSN never load `@sentry/node`, so it stays an OPTIONAL peer dependency: the
|
|
7
|
-
* base install ships without it and the no-op path needs no package at all.
|
|
8
|
-
*
|
|
9
|
-
* `@sentry/node` is imported dynamically and its surface is narrowed to the
|
|
10
|
-
* two calls we use — a hard static import would make the package mandatory.
|
|
11
|
-
*/
|
|
12
|
-
interface SentryLike {
|
|
13
|
-
init(options: {
|
|
14
|
-
dsn: string;
|
|
15
|
-
environment?: string;
|
|
16
|
-
release?: string;
|
|
17
|
-
tracesSampleRate: number;
|
|
18
|
-
}): void;
|
|
19
|
-
captureException(
|
|
20
|
-
error: unknown,
|
|
21
|
-
context?: {
|
|
22
|
-
tags?: Record<string, string>;
|
|
23
|
-
user?: { id?: string };
|
|
24
|
-
extra?: Record<string, unknown>;
|
|
25
|
-
}
|
|
26
|
-
): void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export class SentryErrorReporter implements IErrorReporter {
|
|
30
|
-
private constructor(private readonly sentry: SentryLike) {}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Initialize Sentry and return a reporter, or `null` if `@sentry/node` is
|
|
34
|
-
* not installed (optional dependency absent). Never throws — a broken
|
|
35
|
-
* error-tracker must not take down boot.
|
|
36
|
-
*/
|
|
37
|
-
static async create(opts: {
|
|
38
|
-
dsn: string;
|
|
39
|
-
environment?: string;
|
|
40
|
-
release?: string;
|
|
41
|
-
}): Promise<SentryErrorReporter | null> {
|
|
42
|
-
try {
|
|
43
|
-
// Dynamic so the package stays optional. `@vite-ignore` keeps the
|
|
44
|
-
// bundler from trying to resolve it at build time when it's absent.
|
|
45
|
-
// `@ts-ignore` (not `@ts-expect-error`) because whether the module
|
|
46
|
-
// resolves at type-check time depends on the environment — it's a real
|
|
47
|
-
// dep in some installs, absent in others — so we can't assert the error
|
|
48
|
-
// is always present. The `as unknown as` cast narrows whatever loads to
|
|
49
|
-
// the two calls we use.
|
|
50
|
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
51
|
-
// @ts-ignore -- optional dependency, may be absent at type-check time
|
|
52
|
-
const sentry = (await import(/* @vite-ignore */ '@sentry/node')) as unknown as SentryLike;
|
|
53
|
-
sentry.init({
|
|
54
|
-
dsn: opts.dsn,
|
|
55
|
-
environment: opts.environment,
|
|
56
|
-
release: opts.release,
|
|
57
|
-
// Error tracking only — no performance tracing by default. Flip
|
|
58
|
-
// this up later if you want transactions/spans.
|
|
59
|
-
tracesSampleRate: 0
|
|
60
|
-
});
|
|
61
|
-
return new SentryErrorReporter(sentry);
|
|
62
|
-
} catch (err) {
|
|
63
|
-
console.error(
|
|
64
|
-
'[ErrorReporter] A Sentry DSN is set but @sentry/node could not be loaded. ' +
|
|
65
|
-
'Install it (`pnpm add @sentry/node`) or unset the DSN. Falling back to no-op.',
|
|
66
|
-
err
|
|
67
|
-
);
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
capture(error: unknown, context?: ErrorContext): void {
|
|
73
|
-
try {
|
|
74
|
-
const tags: Record<string, string> = { ...context?.tags };
|
|
75
|
-
if (context?.method) tags.method = context.method;
|
|
76
|
-
if (context?.route) tags.route = context.route;
|
|
77
|
-
if (context?.orgId) tags.orgId = context.orgId;
|
|
78
|
-
this.sentry.captureException(error, {
|
|
79
|
-
tags,
|
|
80
|
-
user: context?.userId ? { id: context.userId } : undefined
|
|
81
|
-
});
|
|
82
|
-
} catch (err) {
|
|
83
|
-
// Reporting is best-effort and sits on the error path — a second
|
|
84
|
-
// failure here would mask the first. Swallow after logging.
|
|
85
|
-
console.error('[ErrorReporter] Failed to capture error:', err);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
package/src/errors/index.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { declaredBodySizeExceeds } from '../body-size.js';
|
|
3
|
-
|
|
4
|
-
function headersWith(contentLength?: string): Headers {
|
|
5
|
-
const h = new Headers();
|
|
6
|
-
if (contentLength !== undefined) h.set('content-length', contentLength);
|
|
7
|
-
return h;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
describe('declaredBodySizeExceeds', () => {
|
|
11
|
-
it('flags a declared size over the limit', () => {
|
|
12
|
-
expect(declaredBodySizeExceeds(headersWith('1001'), 1000)).toBe(true);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('passes a declared size at or under the limit', () => {
|
|
16
|
-
expect(declaredBodySizeExceeds(headersWith('1000'), 1000)).toBe(false);
|
|
17
|
-
expect(declaredBodySizeExceeds(headersWith('0'), 1000)).toBe(false);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('passes when Content-Length is absent (chunked transfer — global limit is the backstop)', () => {
|
|
21
|
-
expect(declaredBodySizeExceeds(headersWith(), 1000)).toBe(false);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('passes a non-numeric Content-Length rather than throwing', () => {
|
|
25
|
-
expect(declaredBodySizeExceeds(headersWith('not-a-number'), 1000)).toBe(false);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { createRouteClassifier } from '../route-classifier.js';
|
|
3
|
-
|
|
4
|
-
// Generic values — the consuming app's actual route policy is pinned by its
|
|
5
|
-
// own test suite; these cover the classifier mechanics.
|
|
6
|
-
const classifier = createRouteClassifier({
|
|
7
|
-
publicPages: ['/', '/login'],
|
|
8
|
-
publicPrefixes: ['/auth/', '/logout'],
|
|
9
|
-
publicApis: ['/api/health'],
|
|
10
|
-
selfGatingPrefix: '/api/files/',
|
|
11
|
-
staticPrefixes: ['/_app/', '/favicon/'],
|
|
12
|
-
staticPaths: ['/favicon.svg', '/robots.txt']
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
describe('createRouteClassifier', () => {
|
|
16
|
-
it('classifies exact pages, API allowlist, prefixes, and self-gating as public', () => {
|
|
17
|
-
for (const path of [
|
|
18
|
-
'/',
|
|
19
|
-
'/login',
|
|
20
|
-
'/api/health',
|
|
21
|
-
'/auth/oauth/callback',
|
|
22
|
-
'/logout',
|
|
23
|
-
'/api/files/orgs/abc/logo.webp'
|
|
24
|
-
]) {
|
|
25
|
-
expect(classifier.isPublicRoute(path)).toBe(true);
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('denies by default — unknown routes are gated', () => {
|
|
30
|
-
for (const path of ['/library', '/api/projects', '/billing', '/whatever']) {
|
|
31
|
-
expect(classifier.isPublicRoute(path)).toBe(false);
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('exact-match pages do not admit siblings via prefix', () => {
|
|
36
|
-
expect(classifier.isPublicRoute('/login-other')).toBe(false);
|
|
37
|
-
expect(classifier.isPublicRoute('/api/healthz')).toBe(false);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('self-gating prefix is not a loose match', () => {
|
|
41
|
-
expect(classifier.isSelfGatingApiRoute('/api/files/x')).toBe(true);
|
|
42
|
-
expect(classifier.isSelfGatingApiRoute('/api/filesX/y')).toBe(false);
|
|
43
|
-
expect(classifier.isSelfGatingApiRoute('/api/projects')).toBe(false);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('recognizes static assets by prefix and exact path', () => {
|
|
47
|
-
expect(classifier.isStaticAsset('/_app/immutable/x.js')).toBe(true);
|
|
48
|
-
expect(classifier.isStaticAsset('/favicon/16.png')).toBe(true);
|
|
49
|
-
expect(classifier.isStaticAsset('/robots.txt')).toBe(true);
|
|
50
|
-
expect(classifier.isStaticAsset('/favicon-other.png')).toBe(false);
|
|
51
|
-
expect(classifier.isStaticAsset('/login')).toBe(false);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('every allowlist is optional — an empty config gates everything', () => {
|
|
55
|
-
const closed = createRouteClassifier({});
|
|
56
|
-
expect(closed.isPublicRoute('/')).toBe(false);
|
|
57
|
-
expect(closed.isStaticAsset('/robots.txt')).toBe(false);
|
|
58
|
-
expect(closed.isSelfGatingApiRoute('/api/files/x')).toBe(false);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { safeRedirectTarget } from '../redirect.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* `safeRedirectTarget` is the post-login redirect validator. The point of
|
|
6
|
-
* the function is to reject *exactly* the values that would otherwise
|
|
7
|
-
* smuggle an external host into a `redirect(303, …)` call:
|
|
8
|
-
* protocol-relative URLs (`//evil.com`) and back-slash variants some
|
|
9
|
-
* browsers normalize as a path separator. Each row below is one such case.
|
|
10
|
-
*/
|
|
11
|
-
describe('safeRedirectTarget', () => {
|
|
12
|
-
it.each([
|
|
13
|
-
['/foo', '/foo'],
|
|
14
|
-
['/foo/bar', '/foo/bar'],
|
|
15
|
-
['/foo?x=1', '/foo?x=1'],
|
|
16
|
-
['/foo#frag', '/foo#frag'],
|
|
17
|
-
['/a', '/a'] // shortest valid path: `/` + non-`/` char
|
|
18
|
-
])('accepts same-origin path %j → %j', (input, expected) => {
|
|
19
|
-
expect(safeRedirectTarget(input, '/fallback')).toBe(expected);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it.each([
|
|
23
|
-
['//evil.com', 'protocol-relative URL'],
|
|
24
|
-
['//evil.com/path', 'protocol-relative URL with path'],
|
|
25
|
-
['/\\evil.com', 'back-slash bypass'],
|
|
26
|
-
['https://evil.com', 'absolute URL'],
|
|
27
|
-
['http://evil.com', 'absolute URL'],
|
|
28
|
-
['javascript:alert(1)', 'javascript: scheme'],
|
|
29
|
-
['', 'empty string'],
|
|
30
|
-
['/', 'lone slash (length<2)'],
|
|
31
|
-
['evil.com', 'no leading slash'],
|
|
32
|
-
['./relative', 'relative path']
|
|
33
|
-
])('rejects %j (%s)', (input) => {
|
|
34
|
-
expect(safeRedirectTarget(input, '/fallback')).toBe('/fallback');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it.each([null, undefined])('rejects %j', (input) => {
|
|
38
|
-
expect(safeRedirectTarget(input, '/fallback')).toBe('/fallback');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('passes through arbitrary non-string types as fallback', () => {
|
|
42
|
-
// safeRedirectTarget's signature is `string | null | undefined`, but
|
|
43
|
-
// some call sites get `unknown` from FormData — make sure we don't
|
|
44
|
-
// throw on a stray non-string.
|
|
45
|
-
expect(safeRedirectTarget(42 as unknown as string, '/fallback')).toBe('/fallback');
|
|
46
|
-
});
|
|
47
|
-
});
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { applySecurityHeaders } from '../security-headers.js';
|
|
3
|
-
|
|
4
|
-
describe('applySecurityHeaders', () => {
|
|
5
|
-
it('sets the baseline hardening headers and returns the same response', () => {
|
|
6
|
-
const response = new Response('ok');
|
|
7
|
-
const out = applySecurityHeaders(response, { hsts: false });
|
|
8
|
-
expect(out).toBe(response);
|
|
9
|
-
expect(out.headers.get('X-Content-Type-Options')).toBe('nosniff');
|
|
10
|
-
expect(out.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin');
|
|
11
|
-
expect(out.headers.get('Permissions-Policy')).toContain('camera=()');
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it('sets HSTS only when asked (production)', () => {
|
|
15
|
-
const dev = applySecurityHeaders(new Response('ok'), { hsts: false });
|
|
16
|
-
expect(dev.headers.get('Strict-Transport-Security')).toBeNull();
|
|
17
|
-
|
|
18
|
-
const prod = applySecurityHeaders(new Response('ok'), { hsts: true });
|
|
19
|
-
expect(prod.headers.get('Strict-Transport-Security')).toBe(
|
|
20
|
-
'max-age=31536000; includeSubDomains'
|
|
21
|
-
);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('never sets CSP or frame headers (iframe embedding is a product requirement)', () => {
|
|
25
|
-
const out = applySecurityHeaders(new Response('ok'), { hsts: true });
|
|
26
|
-
expect(out.headers.get('Content-Security-Policy')).toBeNull();
|
|
27
|
-
expect(out.headers.get('X-Frame-Options')).toBeNull();
|
|
28
|
-
});
|
|
29
|
-
});
|