backend-skeleton 1.0.0-beta.9 → 1.1.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 +185 -13
- package/bin/bskel.mjs +689 -33
- package/contracts/emit.mjs +5 -1
- package/contracts/export.mjs +65 -7
- package/contracts/openapi.mjs +321 -30
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +123 -30
- package/handles/capability-codec.mjs +94 -0
- package/handles/codec.mjs +13 -3
- package/handles/providers/java-spring/emit.mjs +78 -33
- package/handles/providers/java-spring/observe.mjs +4 -3
- package/handles/providers/java-spring/plan.mjs +73 -16
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +19 -9
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +36 -9
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
- package/handles/providers/java-spring.mjs +8 -0
- package/handles/providers/python-fastapi/emit.mjs +21 -26
- package/handles/providers/python-fastapi/observe.mjs +6 -5
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +129 -39
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +144 -55
- package/handles/providers/typescript-express/observe.mjs +102 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
- package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
- package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
- package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
- package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
- package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
- package/handles/providers/typescript-express.mjs +7 -4
- package/lib/attest.mjs +40 -0
- package/lib/cli.mjs +136 -5
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -0
- package/lib/exit-codes.mjs +21 -0
- package/lib/fsutil.mjs +7 -2
- package/lib/gate-definitions.mjs +85 -1
- package/lib/gates.mjs +5 -1
- package/lib/http-server.mjs +192 -6
- package/lib/lock.mjs +68 -15
- package/lib/patch-kinds.mjs +52 -0
- package/lib/patch-transactions.mjs +206 -0
- package/lib/serve-ui.html +211 -0
- package/lib/verify.mjs +23 -6
- package/lib/workflow.mjs +31 -3
- package/package.json +8 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
- package/scanners/adapters/java-spring.mjs +114 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/python-fastapi.mjs +9 -1
- package/scanners/adapters/typescript-express.mjs +19 -2
- package/scanners/db/ddl-apply.mjs +253 -0
- package/scanners/db/introspect.mjs +61 -32
- package/scanners/db/migrations.mjs +73 -18
- package/schemas/cross-feature-report.schema.json +66 -0
- package/schemas/cross-feature-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +3 -3
- package/schemas/gate-attestation.schema.json +22 -0
- package/schemas/gate-export.schema.json +58 -0
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.schema.json +58 -0
- package/schemas/patch-transaction.schema.json +182 -0
- package/schemas/scan-report.schema.json +6 -4
- package/schemas/stack-choice.schema.json +12 -1
- package/schemas/stack-record.schema.json +6 -1
- package/stack/apply.mjs +51 -7
- package/stack/catalog/ngrok.yml +8 -2
- package/stack/config-apply.mjs +168 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
|
|
2
|
+
//
|
|
3
|
+
// D-typescript-express-registry-parity: the opt-in AUTOMATIC half of the handle lifecycle --
|
|
4
|
+
// apply `recordSnapshot(...)` to wrap an EXISTING service function (never generated onto one --
|
|
5
|
+
// a human decides which functions are worth handle-tracking) to have it automatically register
|
|
6
|
+
// the resource-level handle and record the request/response/error envelope around every call.
|
|
7
|
+
//
|
|
8
|
+
// Java splits this into a marker annotation (@RecordHandleSnapshot) + a Spring AOP interceptor
|
|
9
|
+
// (HandleAspect) because Java has no other way to intercept a method call. Python collapses both
|
|
10
|
+
// into one decorator, because Python decorators natively ARE that ecosystem's interception
|
|
11
|
+
// mechanism. TypeScript has NEITHER available here: TS decorators are an experimental,
|
|
12
|
+
// `experimentalDecorators`-gated feature that targets class methods, not the standalone async
|
|
13
|
+
// functions this provider's own resolvers/routes are written as -- and this provider's own
|
|
14
|
+
// codec.ts already establishes "zero non-erasable TypeScript syntax (no decorators...)" as a
|
|
15
|
+
// real, load-bearing constraint. The equivalent here is a HIGHER-ORDER FUNCTION: one mechanism,
|
|
16
|
+
// same "no marker/interceptor split" property Python's decorator has, without decorator syntax.
|
|
17
|
+
//
|
|
18
|
+
// Only an async wrapper exists (no sync variant) -- unlike Python, where a decorator can wrap
|
|
19
|
+
// either a sync or async function and must detect which at decoration time, every Express
|
|
20
|
+
// handler and TypeORM repository call in this ecosystem is already async by convention, so
|
|
21
|
+
// there's no second code path to maintain.
|
|
22
|
+
//
|
|
23
|
+
// `resourceUidArg` is an argument INDEX (not a parameter NAME) -- TypeScript/JavaScript has no
|
|
24
|
+
// runtime reflection equivalent to Python's `inspect.signature(fn).bind(...)`, so this cannot
|
|
25
|
+
// resolve a named parameter the way the Python port does; pass the zero-based position of the
|
|
26
|
+
// resource-uid argument in the wrapped function's own parameter list. `redact` is an explicit
|
|
27
|
+
// array of JSON Pointers, never a guessed heuristic. A failure recording a snapshot is ALWAYS
|
|
28
|
+
// logged and swallowed, never allowed to fail the real business call it wraps -- snapshot
|
|
29
|
+
// recording is best-effort observability, not a new way for an unrelated call to start failing.
|
|
30
|
+
//
|
|
31
|
+
// Requires nothing extra installed (unlike Java's spring-boot-starter-aop) -- a plain function
|
|
32
|
+
// needs no framework support -- but still requires a human to apply it to their own code;
|
|
33
|
+
// codegen never touches an existing business logic file.
|
|
34
|
+
//
|
|
35
|
+
// Example:
|
|
36
|
+
// export const updateOrganization = recordSnapshot(
|
|
37
|
+
// { resourceType: 'Organization', operationId: 'updateOrganization', resourceUidArg: 0, redact: ['/internalNote'] },
|
|
38
|
+
// async (organizationId: string, req: UpdateOrganizationRequest): Promise<OrganizationResponse> => { ... },
|
|
39
|
+
// );
|
|
40
|
+
import type { DataSource } from 'typeorm';
|
|
41
|
+
import { registerHandle, recordSnapshot as persistSnapshot } from './handleService';
|
|
42
|
+
import { deriveHandleUid } from './codec';
|
|
43
|
+
import { resolverFor } from './registry';
|
|
44
|
+
|
|
45
|
+
export interface RecordSnapshotOptions {
|
|
46
|
+
resourceType: string;
|
|
47
|
+
operationId: string;
|
|
48
|
+
resourceUidArg: number;
|
|
49
|
+
redact?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function redactInPlace(obj: unknown, pointer: string): void {
|
|
53
|
+
if (!pointer.startsWith('/')) return;
|
|
54
|
+
const parts = pointer.slice(1).split('/').map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
55
|
+
let current: unknown = obj;
|
|
56
|
+
for (const part of parts.slice(0, -1)) {
|
|
57
|
+
if (current == null) return;
|
|
58
|
+
current = Array.isArray(current) ? current[Number(part)] : (current as Record<string, unknown>)[part];
|
|
59
|
+
}
|
|
60
|
+
const leaf = parts[parts.length - 1];
|
|
61
|
+
if (Array.isArray(current)) {
|
|
62
|
+
const idx = Number(leaf);
|
|
63
|
+
if (!Number.isNaN(idx) && idx < current.length) current[idx] = '***REDACTED***';
|
|
64
|
+
} else if (current != null && typeof current === 'object') {
|
|
65
|
+
if (leaf in (current as Record<string, unknown>)) (current as Record<string, unknown>)[leaf] = '***REDACTED***';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function safely(action: () => Promise<void>, handleUid: string, envelopeDir: string): void {
|
|
70
|
+
action().catch((err) => {
|
|
71
|
+
// eslint-disable-next-line no-console
|
|
72
|
+
console.warn(`recordSnapshot: could not record ${envelopeDir} snapshot for handle ${handleUid} -- the wrapped call proceeds unaffected`, err);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function recordSnapshot<Args extends unknown[], R>(
|
|
77
|
+
options: RecordSnapshotOptions,
|
|
78
|
+
dataSource: DataSource,
|
|
79
|
+
fn: (...args: Args) => Promise<R>,
|
|
80
|
+
): (...args: Args) => Promise<R> {
|
|
81
|
+
const { resourceType, operationId, resourceUidArg, redact = [] } = options;
|
|
82
|
+
return async (...args: Args): Promise<R> => {
|
|
83
|
+
const resolver = resolverFor(resourceType);
|
|
84
|
+
if (resolver == null) {
|
|
85
|
+
// eslint-disable-next-line no-console
|
|
86
|
+
console.warn(`recordSnapshot: no resolver registered for resourceType "${resourceType}" -- skipping snapshot recording, the wrapped call proceeds unaffected`);
|
|
87
|
+
return fn(...args);
|
|
88
|
+
}
|
|
89
|
+
const resourceUid = args[resourceUidArg];
|
|
90
|
+
if (typeof resourceUid !== 'string') {
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.warn(`recordSnapshot: resourceUidArg ${resourceUidArg} on a call to a wrapped "${resourceType}" function did not resolve to a string argument -- skipping snapshot recording, the wrapped call proceeds unaffected`);
|
|
93
|
+
return fn(...args);
|
|
94
|
+
}
|
|
95
|
+
const handleUid = deriveHandleUid('r', resourceType, resourceUid, null);
|
|
96
|
+
|
|
97
|
+
const record = (envelopeDir: string, payload: unknown) => {
|
|
98
|
+
if (payload != null && typeof payload === 'object') {
|
|
99
|
+
for (const pointer of redact) redactInPlace(payload, pointer);
|
|
100
|
+
}
|
|
101
|
+
safely(async () => {
|
|
102
|
+
await registerHandle(dataSource, { kind: 'r', type: resourceType, resourceUid, pointer: null, featureUid: resolver.featureUid, operationId, contractRef: resolver.contractRef });
|
|
103
|
+
await persistSnapshot(dataSource, handleUid, envelopeDir, operationId, resolver.contractRef, payload);
|
|
104
|
+
}, handleUid, envelopeDir);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// The remaining arguments once resourceUidArg is excluded -- the sole survivor UNWRAPPED (the
|
|
108
|
+
// common case: one DTO), matching Java's/Python's own request-payload projection exactly, so
|
|
109
|
+
// a `redact` pointer written against the DTO's own fields resolves correctly.
|
|
110
|
+
const rest = args.filter((_, i) => i !== resourceUidArg);
|
|
111
|
+
record('request', rest.length === 1 ? rest[0] : rest);
|
|
112
|
+
|
|
113
|
+
let result: R;
|
|
114
|
+
try {
|
|
115
|
+
result = await fn(...args);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
record('error', { message: err instanceof Error ? err.message : String(err) });
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
record('response', result);
|
|
121
|
+
return result;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
// Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
|
|
2
2
|
//
|
|
3
3
|
// In-process registry mapping a handle `type` string to its resolver instance -- deliberately NOT
|
|
4
|
-
// a database table
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// DECISIONS.md.
|
|
4
|
+
// a database table (that's HandleRegistry in handleEntities.ts, an unrelated concept that only
|
|
5
|
+
// shares the word "registry" -- see that file's own header). See D-typescript-express-provider /
|
|
6
|
+
// D-typescript-express-registry-parity in DECISIONS.md.
|
|
8
7
|
|
|
9
8
|
// Unlike the Python provider's ResourceResolver (which threads a per-request SQLAlchemy `session`
|
|
10
|
-
// through every
|
|
11
|
-
//
|
|
12
|
-
// instantiated once at startup (`export const
|
|
13
|
-
// per-request-injected object the way SQLAlchemy's
|
|
14
|
-
// its own DataSource reference directly (see
|
|
15
|
-
//
|
|
9
|
+
// argument through every METHOD CALL, matching FastAPI's own dependency-injection convention),
|
|
10
|
+
// none of fetch()/checkAccess()/patchField()/toPublic() below take a dataSource/session parameter
|
|
11
|
+
// -- TypeORM's `DataSource` is an app-wide singleton instantiated once at startup (`export const
|
|
12
|
+
// AppDataSource = new DataSource(...)`), not a per-request-injected object the way SQLAlchemy's
|
|
13
|
+
// `Session` is. Each generated resolver still imports its own DataSource reference directly (see
|
|
14
|
+
// resolver.ts.tmpl) and exposes it as the `dataSource` FIELD below (not a method parameter) --
|
|
15
|
+
// D-typescript-express-registry-parity's own infrastructure (recordSnapshotWrapper.ts, router.ts's
|
|
16
|
+
// recover()/enforcement logic) reads it from there rather than needing a separate reference of
|
|
17
|
+
// its own. `featureUid`/`contractRef` are the other two live-derived values
|
|
18
|
+
// D-resolver-policy-split exists for (see <Type>Policy.ts.tmpl). All three fields are read by
|
|
19
|
+
// that infrastructure only, never by fetch()/checkAccess()/patchField()/toPublic().
|
|
20
|
+
import type { DataSource } from 'typeorm';
|
|
21
|
+
|
|
16
22
|
export interface ResourceResolver {
|
|
17
23
|
type: string;
|
|
24
|
+
featureUid: string;
|
|
25
|
+
contractRef: string;
|
|
26
|
+
dataSource: DataSource;
|
|
18
27
|
fetch(resourceUid: string): Promise<unknown>;
|
|
19
28
|
checkAccess(obj: unknown): void;
|
|
20
29
|
patchField(obj: unknown, pointer: string, value: unknown): void;
|
|
@@ -24,9 +24,22 @@
|
|
|
24
24
|
import { {{MODEL}} } from '{{MODEL_IMPORT_PATH}}';
|
|
25
25
|
import { {{DATA_SOURCE_NAME}} } from '{{DATA_SOURCE_IMPORT_PATH}}';
|
|
26
26
|
import { register, HandleAccessDeniedError, HandleNotImplementedError, type ResourceResolver } from '../registry';
|
|
27
|
+
import { {{RESOURCE_TYPE}}Policy } from './{{RESOURCE_TYPE_CAMEL}}Policy';
|
|
27
28
|
|
|
28
29
|
const {{RESOURCE_TYPE}}Resolver: ResourceResolver = {
|
|
30
|
+
...{{RESOURCE_TYPE}}Policy,
|
|
31
|
+
// Redundant with the spread above (Policy already carries `type`) -- restated explicitly so
|
|
32
|
+
// this file's own orphan-scan (emit.mjs's resourceTypeOf(), a content-read, not a filename
|
|
33
|
+
// guess) can still find a real `type: 'X'` field without needing to open the sibling Policy
|
|
34
|
+
// file too. A literal, not derived from anything -- if this and the Policy value ever
|
|
35
|
+
// disagreed, the object-literal spread order means this line always wins at runtime, matching
|
|
36
|
+
// what the orphan scan would read.
|
|
29
37
|
type: '{{RESOURCE_TYPE}}',
|
|
38
|
+
// D-typescript-express-registry-parity: exposed here, not in the policy file -- this is an
|
|
39
|
+
// OBJECT REFERENCE (the same app-wide DataSource singleton fetch() already imports below), not
|
|
40
|
+
// a plain regenerable string value the way contractRef/featureUid are. Read by recordSnapshotWrapper.ts
|
|
41
|
+
// and router.ts's recover()/enforcement logic so they never need their own separate DataSource.
|
|
42
|
+
dataSource: {{DATA_SOURCE_NAME}},
|
|
30
43
|
|
|
31
44
|
async fetch(resourceUid: string): Promise<unknown> {
|
|
32
45
|
return {{DATA_SOURCE_NAME}}.getRepository({{MODEL}}).findOne({ where: { {{ID_FIELD}}: resourceUid } });
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Generated by backend-skeleton (bskel handles emit) for feature {{FEATURE_ID}}.
|
|
2
|
+
//
|
|
3
|
+
// D-typescript-express-registry-parity (mirrors java-spring's ResourceResolverPolicyStub.java.tmpl
|
|
4
|
+
// / D-resolver-policy-split): machine-owned companion to {{RESOURCE_TYPE}}Resolver -- every value
|
|
5
|
+
// here is derived directly from the current contract/scan and is safe to regenerate on every
|
|
6
|
+
// `bskel handles emit` run, even when the sibling resolver file has diverged (e.g. a hand-finished
|
|
7
|
+
// patchField()) and is refusing to regenerate. This is a SEPARATE file, not a section of
|
|
8
|
+
// resolver.ts, so a hand-edit to checkAccess()/patchField() there never blocks a live-derived
|
|
9
|
+
// value here (contractRef/featureUid) from updating -- both used to live in one file for this
|
|
10
|
+
// provider's own 1st slice, and classifyFile()'s conflict detection is file-level.
|
|
11
|
+
//
|
|
12
|
+
// Nothing here is ever meant to be hand-edited -- to change one of these values, change the
|
|
13
|
+
// SOURCE it was derived from (the feature contract, etc.) and re-run `bskel handles emit`. A
|
|
14
|
+
// hand-edit here will be silently overwritten on the next run, by design (see the "provably
|
|
15
|
+
// untouched -> safe to regenerate" path in lib/handles-manifest.mjs's classifyFile()).
|
|
16
|
+
export const {{RESOURCE_TYPE}}Policy = {
|
|
17
|
+
type: '{{RESOURCE_TYPE}}',
|
|
18
|
+
contractRef: '{{CONTRACT_REF}}',
|
|
19
|
+
featureUid: '{{FEATURE_UID}}',
|
|
20
|
+
} as const;
|
|
@@ -9,8 +9,38 @@
|
|
|
9
9
|
// against still pins this version) does NOT auto-catch a rejected Promise inside an async route
|
|
10
10
|
// handler the way Express 5 does, and that can't be assumed of an arbitrary target app either way.
|
|
11
11
|
import { Router, type Request, type Response } from 'express';
|
|
12
|
-
import { decodeHandle, resolveJsonPointer } from './codec';
|
|
13
|
-
import { resolverFor, HandleAccessDeniedError, HandleNotImplementedError } from './registry';
|
|
12
|
+
import { decodeHandle, resolveJsonPointer, deriveHandleUid, type DecodedHandle } from './codec';
|
|
13
|
+
import { resolverFor, HandleAccessDeniedError, HandleNotImplementedError, type ResourceResolver } from './registry';
|
|
14
|
+
import { HandleRegistry, HandleSnapshot } from './handleEntities';
|
|
15
|
+
|
|
16
|
+
// O3 (D-handle-registry-enforcement, part 2 of 2), D-typescript-express-registry-parity: opt-in,
|
|
17
|
+
// defaults false -- baked in at `bskel handles emit --enforce-registry on` time, matching every
|
|
18
|
+
// other per-target-repo codegen constant this project bakes in (e.g. featureUid/contractRef on
|
|
19
|
+
// each resolver). `registerHandle()` is only ever called by generated code wrapped with
|
|
20
|
+
// recordSnapshot(...) (an app that opted in) or a target app's own hand-written calls -- an app
|
|
21
|
+
// that mints handles through neither path gets fully working fetch()/patch() with zero registry
|
|
22
|
+
// involvement today; flipping this on unconditionally would 404 every one of their existing
|
|
23
|
+
// handles. Stays a deliberate, informed choice a target app's own maintainer makes.
|
|
24
|
+
const ENFORCE_REGISTRY = {{ENFORCE_REGISTRY}};
|
|
25
|
+
|
|
26
|
+
export class HandleNotRegisteredError extends Error {}
|
|
27
|
+
|
|
28
|
+
// D-handle-registry-enforcement's own bootstrapping-trap fix, ported here from day one (not
|
|
29
|
+
// rediscovered): always derives and looks up the PARENT RESOURCE's own row (kind='r',
|
|
30
|
+
// pointer=null), regardless of the requested handle's own kind/pointer -- `resourceType` is still
|
|
31
|
+
// exactly cross-checked (the real D-security-9 protection: an attacker-controlled `type` can't
|
|
32
|
+
// claim a different, more sensitive resource's registration by UUID coincidence), but kind/pointer
|
|
33
|
+
// are not, since every real registration in this system is resource-level. Structurally correct,
|
|
34
|
+
// not a workaround: revocation is inherently a resource-level concept, matching how fetch()'s own
|
|
35
|
+
// kind=f path already fetches the WHOLE resource and narrows client-side with a pointer.
|
|
36
|
+
async function requireRegisteredOrThrow(resolver: ResourceResolver, decoded: DecodedHandle): Promise<HandleRegistry> {
|
|
37
|
+
const resourceHandleUid = deriveHandleUid('r', decoded.type, decoded.uuid, null);
|
|
38
|
+
const registry = await resolver.dataSource.getRepository(HandleRegistry).findOne({ where: { handleUid: resourceHandleUid } });
|
|
39
|
+
if (registry == null || registry.resourceType !== decoded.type || registry.revokedAt != null) {
|
|
40
|
+
throw new HandleNotRegisteredError('no active registration for this handle');
|
|
41
|
+
}
|
|
42
|
+
return registry;
|
|
43
|
+
}
|
|
14
44
|
|
|
15
45
|
export const router = Router();
|
|
16
46
|
|
|
@@ -38,6 +68,9 @@ router.get('/handles/:handle', async (req: Request, res: Response) => {
|
|
|
38
68
|
}
|
|
39
69
|
|
|
40
70
|
try {
|
|
71
|
+
if (ENFORCE_REGISTRY) {
|
|
72
|
+
await requireRegisteredOrThrow(resolver, decoded);
|
|
73
|
+
}
|
|
41
74
|
const obj = await resolver.fetch(decoded.uuid);
|
|
42
75
|
if (obj == null) {
|
|
43
76
|
res.status(404).json({ detail: 'resource not found' });
|
|
@@ -65,6 +98,10 @@ router.get('/handles/:handle', async (req: Request, res: Response) => {
|
|
|
65
98
|
res.status(403).json({ detail: exc.message });
|
|
66
99
|
return;
|
|
67
100
|
}
|
|
101
|
+
if (exc instanceof HandleNotRegisteredError) {
|
|
102
|
+
res.status(404).json({ detail: exc.message });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
68
105
|
throw exc;
|
|
69
106
|
}
|
|
70
107
|
});
|
|
@@ -100,6 +137,9 @@ router.patch('/handles/:handle', async (req: Request, res: Response) => {
|
|
|
100
137
|
}
|
|
101
138
|
|
|
102
139
|
try {
|
|
140
|
+
if (ENFORCE_REGISTRY) {
|
|
141
|
+
await requireRegisteredOrThrow(resolver, decoded);
|
|
142
|
+
}
|
|
103
143
|
const obj = await resolver.fetch(decoded.uuid);
|
|
104
144
|
if (obj == null) {
|
|
105
145
|
res.status(404).json({ detail: 'resource not found' });
|
|
@@ -117,6 +157,77 @@ router.patch('/handles/:handle', async (req: Request, res: Response) => {
|
|
|
117
157
|
res.status(501).json({ detail: exc.message });
|
|
118
158
|
return;
|
|
119
159
|
}
|
|
160
|
+
if (exc instanceof HandleNotRegisteredError) {
|
|
161
|
+
res.status(404).json({ detail: exc.message });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
throw exc;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// D-handle-lifecycle (O4), D-typescript-express-registry-parity: recover() structurally REQUIRES
|
|
169
|
+
// a registry row to find a snapshot's own primary key -- there is no "unenforced" mode for it,
|
|
170
|
+
// unlike fetch()/patch() above. Always calls the same shared cross-check those two now optionally
|
|
171
|
+
// call, so the two paths can never silently drift apart. Unlike fetch()/patch(), this does NOT
|
|
172
|
+
// call resolver.fetch() first -- recovering history of a since-deleted resource is legitimate, so
|
|
173
|
+
// this never requires the live resource to still exist.
|
|
174
|
+
router.get('/handles/:handle/recover', async (req: Request, res: Response) => {
|
|
175
|
+
if (typeof req.params.handle !== 'string') {
|
|
176
|
+
res.status(400).json({ detail: 'malformed handle path segment' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
let decoded;
|
|
180
|
+
try {
|
|
181
|
+
decoded = decodeHandle(req.params.handle);
|
|
182
|
+
} catch (exc) {
|
|
183
|
+
res.status(400).json({ detail: (exc as Error).message });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const resolver = resolverFor(decoded.type);
|
|
188
|
+
if (!resolver) {
|
|
189
|
+
res.status(404).json({ detail: `no resolver registered for handle type "${decoded.type}"` });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let registry: HandleRegistry;
|
|
194
|
+
try {
|
|
195
|
+
registry = await requireRegisteredOrThrow(resolver, decoded);
|
|
196
|
+
} catch (exc) {
|
|
197
|
+
if (exc instanceof HandleNotRegisteredError) {
|
|
198
|
+
res.status(404).json({ detail: exc.message });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
120
201
|
throw exc;
|
|
121
202
|
}
|
|
203
|
+
|
|
204
|
+
const atParam = typeof req.query.at === 'string' ? req.query.at : null;
|
|
205
|
+
const at = atParam != null ? new Date(atParam) : null;
|
|
206
|
+
if (at != null && Number.isNaN(at.getTime())) {
|
|
207
|
+
res.status(400).json({ detail: `"at" query parameter is not a valid date: "${atParam}"` });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const qb = resolver.dataSource.getRepository(HandleSnapshot)
|
|
212
|
+
.createQueryBuilder('snapshot')
|
|
213
|
+
.where('snapshot.handle_uid = :handleUid', { handleUid: registry.handleUid })
|
|
214
|
+
.orderBy('snapshot.recorded_at', 'DESC');
|
|
215
|
+
if (at != null) qb.andWhere('snapshot.recorded_at <= :at', { at });
|
|
216
|
+
const snapshot = await qb.getOne();
|
|
217
|
+
|
|
218
|
+
if (snapshot == null) {
|
|
219
|
+
res.status(404).json({ detail: `no snapshot recorded for this handle${at != null ? ` at or before ${at.toISOString()}` : ''}` });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
res.status(200).json({
|
|
224
|
+
handle: req.params.handle,
|
|
225
|
+
recordedAt: snapshot.recordedAt,
|
|
226
|
+
operationId: snapshot.operationId,
|
|
227
|
+
// snapshot.payload is already a native object/array/scalar (a real jsonb column) -- no
|
|
228
|
+
// manual re-parse step exists here for the double-encoding bug Java once had to be possible
|
|
229
|
+
// in the first place.
|
|
230
|
+
payload: snapshot.payload,
|
|
231
|
+
schemaDrift: registry.contractRef !== snapshot.contractHash,
|
|
232
|
+
});
|
|
122
233
|
});
|
|
@@ -9,10 +9,13 @@ export const provider = {
|
|
|
9
9
|
id: 'typescript-express',
|
|
10
10
|
title: 'TypeScript / Express / TypeORM',
|
|
11
11
|
requiresCapabilities: ['resource.fetch'],
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
12
|
+
// D-typescript-express-registry-parity: migration.sql is manifest-tracked now (like every
|
|
13
|
+
// other provider's, per D-write-safety-phase0), so this declaration is NOT for idempotence
|
|
14
|
+
// exclusion -- it's lib/verify.mjs's S6 safety net (a `handles ran` check that fires even with
|
|
15
|
+
// no manifest entry at all, e.g. a `gate force`d handles gate). See java-spring.mjs's own
|
|
16
|
+
// identical comment for the full reasoning; checkArtifacts() dedupes against the manifest-
|
|
17
|
+
// based check by path, so this never produces a duplicate once a real manifest entry exists.
|
|
18
|
+
outputs: { spec: ['handles/migration.sql'] },
|
|
16
19
|
plan,
|
|
17
20
|
emit(args) {
|
|
18
21
|
return emitTypeScriptExpress(args);
|
package/lib/attest.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// D-gate-attestation-signing: cryptographic primitives for signed gate attestations -- Node's
|
|
2
|
+
// built-in `crypto` module only (Ed25519, available since Node 12, well within this project's
|
|
3
|
+
// >=18 floor), zero new dependencies. Deliberately minimal: this module knows how to canonicalize,
|
|
4
|
+
// sign, and verify a JSON payload -- it has no opinion about WHERE a key lives (bin/bskel.mjs's
|
|
5
|
+
// `--key`/`--pubkey` flags are the only interface, per the user's own explicit choice to reject a
|
|
6
|
+
// new home-directory key-storage convention for this slice -- see DECISIONS.md).
|
|
7
|
+
import { generateKeyPairSync, sign as cryptoSign, verify as cryptoVerify } from 'node:crypto';
|
|
8
|
+
import { sortKeysDeep } from './gates.mjs';
|
|
9
|
+
|
|
10
|
+
export function generateKeypair() {
|
|
11
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
12
|
+
return {
|
|
13
|
+
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }),
|
|
14
|
+
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Deep-sorted, whitespace-free JSON -- the ONLY thing that's ever actually signed/verified.
|
|
19
|
+
// Reusing lib/gates.mjs's own sortKeysDeep() (already proven correct via every gate's `inputs`
|
|
20
|
+
// field) rather than a second, possibly-subtly-different implementation.
|
|
21
|
+
export function canonicalize(value) {
|
|
22
|
+
return JSON.stringify(sortKeysDeep(value));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function signPayload(payload, privateKeyPem) {
|
|
26
|
+
const canonical = canonicalize(payload);
|
|
27
|
+
return cryptoSign(null, Buffer.from(canonical), privateKeyPem).toString('base64');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Returns a plain boolean, never throws on a malformed signature/key -- a corrupt or wrong-format
|
|
31
|
+
// signature is exactly as "not valid" as a mismatched one, not a distinct error class a caller
|
|
32
|
+
// needs to handle differently.
|
|
33
|
+
export function verifyPayload(payload, signatureB64, publicKeyPem) {
|
|
34
|
+
const canonical = canonicalize(payload);
|
|
35
|
+
try {
|
|
36
|
+
return cryptoVerify(null, Buffer.from(canonical), publicKeyPem, Buffer.from(signatureB64, 'base64'));
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/lib/cli.mjs
CHANGED
|
@@ -85,10 +85,29 @@ export const COMMANDS = {
|
|
|
85
85
|
// D-gate-export: unlike `gate show`, always feature-scoped -- the whole point is one feature's
|
|
86
86
|
// own evidence trail across all 5 gates, not a single gate/repo-level snapshot.
|
|
87
87
|
'gate export': {
|
|
88
|
-
usage: 'bskel gate export --feature <id> [--out <path>] [--json]',
|
|
88
|
+
usage: 'bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath>] [--json]',
|
|
89
89
|
options: {
|
|
90
90
|
feature: { type: 'string', default: null, required: true },
|
|
91
91
|
out: { type: 'string', default: null },
|
|
92
|
+
sign: { type: 'boolean', default: false },
|
|
93
|
+
key: { type: 'string', default: null },
|
|
94
|
+
json: { type: 'boolean', default: false },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
// D-gate-attestation-signing.
|
|
98
|
+
'attest keygen': {
|
|
99
|
+
usage: 'bskel attest keygen --out <dir> [--force] [--json]',
|
|
100
|
+
options: {
|
|
101
|
+
out: { type: 'string', default: null, required: true },
|
|
102
|
+
force: { type: 'boolean', default: false },
|
|
103
|
+
json: { type: 'boolean', default: false },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
'attest verify': {
|
|
107
|
+
usage: 'bskel attest verify --file <path> --pubkey <path> [--json]',
|
|
108
|
+
options: {
|
|
109
|
+
file: { type: 'string', default: null, required: true },
|
|
110
|
+
pubkey: { type: 'string', default: null, required: true },
|
|
92
111
|
json: { type: 'boolean', default: false },
|
|
93
112
|
},
|
|
94
113
|
},
|
|
@@ -129,6 +148,30 @@ export const COMMANDS = {
|
|
|
129
148
|
},
|
|
130
149
|
allowPositionals: true,
|
|
131
150
|
},
|
|
151
|
+
'scan cross-feature-check': {
|
|
152
|
+
usage: 'bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]',
|
|
153
|
+
options: {
|
|
154
|
+
feature: { type: 'string', default: null, required: true },
|
|
155
|
+
// D-cross-feature-fk-inference: byte-identical shape to `scan`'s own --db/--database-url-env/
|
|
156
|
+
// --schema (resolved via the SAME resolveDbSchemaOrExit() helper) -- omitting them entirely
|
|
157
|
+
// is the exact prior behavior, unchanged.
|
|
158
|
+
db: { type: 'boolean', default: false },
|
|
159
|
+
'database-url-env': { type: 'string', default: null },
|
|
160
|
+
schema: { type: 'string', default: 'public' },
|
|
161
|
+
json: { type: 'boolean', default: false },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
'scan cross-feature-waive': {
|
|
165
|
+
usage: 'bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..." [--json]',
|
|
166
|
+
options: {
|
|
167
|
+
feature: { type: 'string', default: null, required: true },
|
|
168
|
+
signal: { type: 'string', default: null, required: true },
|
|
169
|
+
identifier: { type: 'string', default: null, required: true },
|
|
170
|
+
'other-feature': { type: 'string', default: null, required: true },
|
|
171
|
+
reason: { type: 'string', default: '' },
|
|
172
|
+
json: { type: 'boolean', default: false },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
132
175
|
'feature init': {
|
|
133
176
|
usage: 'bskel feature init --slug <name>',
|
|
134
177
|
options: { slug: { type: 'string', default: null, required: true } },
|
|
@@ -263,11 +306,15 @@ export const COMMANDS = {
|
|
|
263
306
|
},
|
|
264
307
|
},
|
|
265
308
|
'stack apply': {
|
|
266
|
-
usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--json]',
|
|
309
|
+
usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] [--json]',
|
|
267
310
|
options: {
|
|
268
311
|
choice: { type: 'string', default: null },
|
|
269
312
|
apply: { type: 'boolean', default: false },
|
|
270
313
|
port: { type: 'string', default: '8080', numeric: { min: 1, max: 65535 } },
|
|
314
|
+
// D-write-safety-phase0 (item 2): mirrors handles emit's own --force/--reason exactly --
|
|
315
|
+
// a file that diverged from what `stack apply` itself last wrote is refused without this.
|
|
316
|
+
force: { type: 'boolean', default: false },
|
|
317
|
+
reason: { type: 'string', default: '' },
|
|
271
318
|
json: { type: 'boolean', default: false },
|
|
272
319
|
},
|
|
273
320
|
},
|
|
@@ -316,11 +363,16 @@ export const COMMANDS = {
|
|
|
316
363
|
// purpose IS the live query). Reuses `--resource type1,type2`'s exact multi-value convention
|
|
317
364
|
// from `handles plan`/`handles emit` rather than a new singular flag name.
|
|
318
365
|
'handles audit': {
|
|
319
|
-
usage: 'bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]',
|
|
366
|
+
usage: 'bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--module <name>] [--check-registry-coverage] [--json]',
|
|
320
367
|
options: {
|
|
321
368
|
feature: { type: 'string', default: null, required: true },
|
|
322
369
|
'database-url-env': { type: 'string', default: null, required: true },
|
|
323
370
|
resource: { type: 'string', default: '' },
|
|
371
|
+
// D-write-safety-phase1 (item 3): both opt-in, only consulted when --check-registry-coverage
|
|
372
|
+
// is set -- the base command stays gate-independent and scan-report-independent otherwise
|
|
373
|
+
// (see this command's own doc comment in bin/bskel.mjs for why that matters).
|
|
374
|
+
module: { type: 'string', default: null },
|
|
375
|
+
'check-registry-coverage': { type: 'boolean', default: false },
|
|
324
376
|
json: { type: 'boolean', default: false },
|
|
325
377
|
},
|
|
326
378
|
},
|
|
@@ -343,10 +395,11 @@ export const COMMANDS = {
|
|
|
343
395
|
},
|
|
344
396
|
},
|
|
345
397
|
'observe import': {
|
|
346
|
-
usage: 'bskel observe import --feature <id> --receipts <path> [--json]',
|
|
398
|
+
usage: 'bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]',
|
|
347
399
|
options: {
|
|
348
400
|
feature: { type: 'string', default: null, required: true },
|
|
349
401
|
receipts: { type: 'string', default: null, required: true },
|
|
402
|
+
'fail-on-violation': { type: 'boolean', default: false },
|
|
350
403
|
json: { type: 'boolean', default: false },
|
|
351
404
|
},
|
|
352
405
|
},
|
|
@@ -410,6 +463,70 @@ export const COMMANDS = {
|
|
|
410
463
|
json: { type: 'boolean', default: false },
|
|
411
464
|
},
|
|
412
465
|
},
|
|
466
|
+
// D-patch-transactions: content-addressed patch transactions, Slice 1 (config_check ->
|
|
467
|
+
// config_apply). `propose`/`approve` only touch specs/, so no --force escape exists on either --
|
|
468
|
+
// re-propose is the only remediation for a stale target. `rollback` alone gets --force (reverting
|
|
469
|
+
// to a known-good, git-recoverable prior state is materially lower-risk than forcing a forward
|
|
470
|
+
// edit whose collateral effects were never re-verified).
|
|
471
|
+
'patch propose': {
|
|
472
|
+
usage: 'bskel patch propose --feature <id> [--kind config-apply --choice <stackChoiceId> --target <config_check target path> | --kind ddl-apply --database-url-env <NAME> --sql-file <path> [--schema public]] [--json]',
|
|
473
|
+
options: {
|
|
474
|
+
feature: { type: 'string', default: null, required: true },
|
|
475
|
+
// D-ddl-apply: default 'config-apply' -- omitting --kind entirely is byte-identical to
|
|
476
|
+
// this project's prior behavior. choice/target/database-url-env/sql-file are validated
|
|
477
|
+
// by hand inside cmdPatchPropose (kind-conditional requirements aren't expressible via
|
|
478
|
+
// this table's own unconditional `required: true`), matching this file's existing
|
|
479
|
+
// convention for kind-conditional flags (e.g. --reason on approve/rollback).
|
|
480
|
+
kind: { type: 'string', default: 'config-apply' },
|
|
481
|
+
choice: { type: 'string', default: null },
|
|
482
|
+
target: { type: 'string', default: null },
|
|
483
|
+
'database-url-env': { type: 'string', default: null },
|
|
484
|
+
schema: { type: 'string', default: 'public' },
|
|
485
|
+
'sql-file': { type: 'string', default: null },
|
|
486
|
+
json: { type: 'boolean', default: false },
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
'patch approve': {
|
|
490
|
+
usage: 'bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]',
|
|
491
|
+
options: {
|
|
492
|
+
feature: { type: 'string', default: null, required: true },
|
|
493
|
+
transaction: { type: 'string', default: null, required: true },
|
|
494
|
+
reason: { type: 'string', default: '' },
|
|
495
|
+
json: { type: 'boolean', default: false },
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
'patch apply': {
|
|
499
|
+
usage: 'bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]',
|
|
500
|
+
options: {
|
|
501
|
+
feature: { type: 'string', default: null, required: true },
|
|
502
|
+
transaction: { type: 'string', default: null, required: true },
|
|
503
|
+
// D-ddl-apply: required for any kind other than config-apply -- what value it must
|
|
504
|
+
// exactly equal is kind- AND transaction-specific (getPatchKind(kind).requiredConfirmValue(txn)):
|
|
505
|
+
// the transaction id for a non-drop ddl-apply transaction, or the sorted, comma-joined
|
|
506
|
+
// dropped-table name(s) for one that drops a table. Checked by hand inside cmdPatchApply
|
|
507
|
+
// once the transaction's own kind/shape is known, not declaratively here. Ignored for
|
|
508
|
+
// config-apply.
|
|
509
|
+
confirm: { type: 'string', default: null },
|
|
510
|
+
json: { type: 'boolean', default: false },
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
'patch rollback': {
|
|
514
|
+
usage: 'bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]',
|
|
515
|
+
options: {
|
|
516
|
+
feature: { type: 'string', default: null, required: true },
|
|
517
|
+
transaction: { type: 'string', default: null, required: true },
|
|
518
|
+
reason: { type: 'string', default: '' },
|
|
519
|
+
force: { type: 'boolean', default: false },
|
|
520
|
+
json: { type: 'boolean', default: false },
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
'patch list': {
|
|
524
|
+
usage: 'bskel patch list --feature <id> [--json]',
|
|
525
|
+
options: {
|
|
526
|
+
feature: { type: 'string', default: null, required: true },
|
|
527
|
+
json: { type: 'boolean', default: false },
|
|
528
|
+
},
|
|
529
|
+
},
|
|
413
530
|
verify: {
|
|
414
531
|
usage: 'bskel verify --feature <id> [--build [--allow-skip-build]] [--json]',
|
|
415
532
|
options: {
|
|
@@ -444,13 +561,27 @@ export const COMMANDS = {
|
|
|
444
561
|
},
|
|
445
562
|
},
|
|
446
563
|
serve: {
|
|
447
|
-
usage: 'bskel serve [--port N] [--host <addr>] [--json]',
|
|
564
|
+
usage: 'bskel serve [--port N] [--host <addr>] [--database-url-env <NAME> [--schema public] [--sign-key <path>] [--require-sign-key]] [--json]',
|
|
448
565
|
options: {
|
|
449
566
|
// min:0 (unlike stack apply --port's min:1) -- 0 is the standard "let the OS pick a free
|
|
450
567
|
// ephemeral port" sentinel, genuinely useful both for tests and for a user who doesn't care
|
|
451
568
|
// which port they get, not just a testing convenience.
|
|
452
569
|
port: { type: 'string', default: '4747', numeric: { min: 0, max: 65535 } },
|
|
453
570
|
host: { type: 'string', default: '127.0.0.1' },
|
|
571
|
+
// D-ddl-apply: every new DB-schema/patch-transaction route is gated behind this one flag
|
|
572
|
+
// being present -- a plain `bskel serve` (no --database-url-env) stays byte-identical to
|
|
573
|
+
// today, those paths simply don't exist (404, not 403), mirroring --host's own "safe
|
|
574
|
+
// default, explicit override" convention. --sign-key is optional even when the DB surface
|
|
575
|
+
// is enabled (see D-ddl-apply's "detect and warn, never hard-require" signing posture).
|
|
576
|
+
'database-url-env': { type: 'string', default: null },
|
|
577
|
+
schema: { type: 'string', default: 'public' },
|
|
578
|
+
'sign-key': { type: 'string', default: null },
|
|
579
|
+
// D-ddl-apply: opt-in-to-MORE-strictness -- refuses to even start the DDL surface without
|
|
580
|
+
// --sign-key also given, closing this feature's own named "mandatory signing... cheap,
|
|
581
|
+
// well-justified near-term addition" EXIT item. A no-op when --database-url-env wasn't
|
|
582
|
+
// given at all (nothing to enforce on a surface that isn't running), same as --schema/
|
|
583
|
+
// --sign-key themselves already being inert outside that case.
|
|
584
|
+
'require-sign-key': { type: 'boolean', default: false },
|
|
454
585
|
json: { type: 'boolean', default: false },
|
|
455
586
|
},
|
|
456
587
|
},
|