@openparachute/vault 0.6.3 → 0.6.4-rc.10
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 +46 -11
- package/core/src/attribution.test.ts +273 -0
- package/core/src/core.test.ts +126 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +235 -9
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/core/src/notes.ts +280 -7
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +51 -19
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +35 -14
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/admin-spa.test.ts +18 -5
- package/src/admin-spa.ts +24 -3
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/cli.ts +345 -9
- package/src/config.ts +11 -0
- package/src/first-boot-create.test.ts +155 -0
- package/src/import-daemon-busy.test.ts +8 -17
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +31 -4
- package/src/mirror-credentials.test.ts +47 -0
- package/src/mirror-credentials.ts +33 -0
- package/src/mirror-history.test.ts +426 -0
- package/src/mirror-manager.ts +202 -22
- package/src/mirror-routes.test.ts +78 -0
- package/src/mirror-routes.ts +195 -1
- package/src/module-config.ts +46 -80
- package/src/routes.ts +209 -41
- package/src/routing.test.ts +115 -25
- package/src/routing.ts +64 -2
- package/src/scale.bench.test.ts +82 -0
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/self-register.test.ts +5 -5
- package/src/self-register.ts +8 -3
- package/src/server.ts +58 -38
- package/src/subscribe.test.ts +23 -2
- package/src/test-support/spawn.ts +85 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
- package/src/vault-remove.test.ts +4 -5
- package/src/vault.test.ts +188 -17
package/src/module-config.ts
CHANGED
|
@@ -14,31 +14,26 @@
|
|
|
14
14
|
*
|
|
15
15
|
* PUT /.parachute/config is Phase 3 — not implemented here.
|
|
16
16
|
*
|
|
17
|
+
* Scope boundary (vault#478): `GET /.parachute/config` is gated by
|
|
18
|
+
* `vault:<name>:admin` — admin over *your* vault only — so it describes and
|
|
19
|
+
* returns ONLY per-vault config, never daemon-GLOBAL settings.
|
|
20
|
+
*
|
|
17
21
|
* Fields currently described:
|
|
18
22
|
* - audio_retention: per-vault enum, backed by VaultConfig.audio_retention.
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* arbitrary scribe; the discovery layer is the gate.
|
|
31
|
-
* - scribeBearer: writeOnly — sourced from SCRIBE_AUTH_TOKEN env var.
|
|
32
|
-
* Hub install generates one at first boot
|
|
33
|
-
* (see scribe-env.ts:ensureScribeBearer); manual
|
|
34
|
-
* rotation is via `parachute-vault config set`.
|
|
35
|
-
* - scribe_url / scribe_token (deprecated): kept under their legacy names
|
|
36
|
-
* through one release for the hub admin SPA's prior
|
|
37
|
-
* render path; new code should read autoTranscribe.*.
|
|
23
|
+
* - autoTranscribe.enabled: per-vault toggle (vault#353). Reports the value
|
|
24
|
+
* THIS vault will use, resolving per-vault override
|
|
25
|
+
* (VaultConfig.auto_transcribe.enabled) → server
|
|
26
|
+
* default (GlobalConfig.auto_transcribe.enabled) →
|
|
27
|
+
* true. The inherited fallback is the per-vault
|
|
28
|
+
* truth, not a leak of the global setting.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately NOT exposed here (daemon-global → operator-only surface):
|
|
31
|
+
* - port: GlobalConfig.port (deployment-wide listen port).
|
|
32
|
+
* - autoTranscribe.scribeUrl / scribe_url: discovery-resolved per-process.
|
|
33
|
+
* - autoTranscribe.scribeBearer / scribe_token: shared SCRIBE_AUTH_TOKEN.
|
|
38
34
|
*/
|
|
39
35
|
|
|
40
36
|
import type { VaultConfig, GlobalConfig } from "./config.ts";
|
|
41
|
-
import { resolveScribeUrl } from "./scribe-discovery.ts";
|
|
42
37
|
|
|
43
38
|
export interface ModuleConfigSchema {
|
|
44
39
|
$schema: string;
|
|
@@ -75,83 +70,54 @@ export function buildConfigSchema(): ModuleConfigSchema {
|
|
|
75
70
|
default: true,
|
|
76
71
|
title: "Enable auto-transcription",
|
|
77
72
|
description:
|
|
78
|
-
"
|
|
79
|
-
},
|
|
80
|
-
scribeUrl: {
|
|
81
|
-
type: "string",
|
|
82
|
-
format: "uri",
|
|
83
|
-
readOnly: true,
|
|
84
|
-
title: "Scribe URL",
|
|
85
|
-
description:
|
|
86
|
-
"URL of the scribe service. Auto-populated from `~/.parachute/services.json` at vault startup (or from the SCRIBE_URL env var when set). Read-only — operators can't point at an arbitrary scribe.",
|
|
87
|
-
},
|
|
88
|
-
scribeBearer: {
|
|
89
|
-
type: "string",
|
|
90
|
-
writeOnly: true,
|
|
91
|
-
title: "Scribe auth bearer",
|
|
92
|
-
description:
|
|
93
|
-
"Shared bearer for the vault→scribe loopback contract. Hub install generates one at first boot. Write-only — never returned by GET.",
|
|
73
|
+
"Per-vault toggle. Default on — audio uploads transcribe automatically when scribe is reachable. Set to false to disable for THIS vault. Resolves per-vault override → server default → on, so leaving it unset inherits the deployment default.",
|
|
94
74
|
},
|
|
95
75
|
},
|
|
96
76
|
},
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
description:
|
|
104
|
-
"Legacy alias for `autoTranscribe.scribeUrl`. Will be removed in a future release.",
|
|
105
|
-
readOnly: true,
|
|
106
|
-
deprecated: true,
|
|
107
|
-
},
|
|
108
|
-
scribe_token: {
|
|
109
|
-
type: "string",
|
|
110
|
-
title: "Scribe auth token (deprecated alias)",
|
|
111
|
-
description:
|
|
112
|
-
"Legacy alias for `autoTranscribe.scribeBearer`. Will be removed in a future release.",
|
|
113
|
-
writeOnly: true,
|
|
114
|
-
deprecated: true,
|
|
115
|
-
},
|
|
116
|
-
port: {
|
|
117
|
-
type: "integer",
|
|
118
|
-
minimum: 1,
|
|
119
|
-
maximum: 65535,
|
|
120
|
-
title: "HTTP port",
|
|
121
|
-
description: "Port the vault server listens on. Set at init time; changing requires a restart.",
|
|
122
|
-
readOnly: true,
|
|
123
|
-
},
|
|
77
|
+
// NOTE (vault#478): daemon-GLOBAL fields (the listen `port`, the
|
|
78
|
+
// discovery-resolved scribe URL, the shared scribe bearer) are
|
|
79
|
+
// deliberately NOT described here. This endpoint is gated by
|
|
80
|
+
// `vault:<name>:admin` — admin over *your* vault only — so it neither
|
|
81
|
+
// describes nor returns deployment-wide settings. Those live behind the
|
|
82
|
+
// operator-only surface (CLI / global config).
|
|
124
83
|
},
|
|
125
84
|
};
|
|
126
85
|
}
|
|
127
86
|
|
|
128
87
|
/**
|
|
129
|
-
* Effective config values
|
|
130
|
-
*
|
|
131
|
-
*
|
|
88
|
+
* Effective config values for ONE vault. The shared scribe bearer is
|
|
89
|
+
* daemon-global and never returned (see the scope boundary below).
|
|
90
|
+
*
|
|
91
|
+
* Scope boundary (vault#478): the `GET /vault/<name>/.parachute/config`
|
|
92
|
+
* endpoint is gated by `vault:<name>:admin` — "admin over *your* vault only".
|
|
93
|
+
* It must therefore return ONLY per-vault config, never daemon-GLOBAL settings
|
|
94
|
+
* (the listen `port`, the discovery-resolved scribe URL, the server-wide
|
|
95
|
+
* auto-transcribe default). Those describe the whole deployment, not this
|
|
96
|
+
* vault, and leaking them across the per-vault admin boundary matters once a
|
|
97
|
+
* shared multi-vault daemon hands admin-on-one-vault to a beta signup. They
|
|
98
|
+
* live behind the operator-only surface (the CLI / global config), not here.
|
|
99
|
+
*
|
|
100
|
+
* `autoTranscribe.enabled` IS per-vault: it reports the value THIS vault will
|
|
101
|
+
* actually use, resolving per-vault override → global default → true (mirrors
|
|
102
|
+
* `shouldAutoTranscribe`). That's a per-vault effective value, not the raw
|
|
103
|
+
* daemon-global toggle — reporting it doesn't leak the global setting (an
|
|
104
|
+
* unset vault simply inherits, which is the per-vault truth).
|
|
132
105
|
*/
|
|
133
106
|
export function buildConfigValues(
|
|
134
107
|
vaultConfig: VaultConfig,
|
|
135
108
|
globalConfig: GlobalConfig,
|
|
136
|
-
env: { SCRIBE_URL?: string | undefined } = process.env as { SCRIBE_URL?: string },
|
|
137
109
|
): Record<string, unknown> {
|
|
138
|
-
// Resolve scribe URL through the discovery layer so the GET shape reflects
|
|
139
|
-
// what the worker will actually use (services.json > SCRIBE_URL > unset).
|
|
140
|
-
// Pass env through so the test harness's override is honored.
|
|
141
|
-
const scribeUrl = resolveScribeUrl(env as NodeJS.ProcessEnv) ?? "";
|
|
142
110
|
return {
|
|
143
111
|
audio_retention: vaultConfig.audio_retention ?? "keep",
|
|
144
112
|
autoTranscribe: {
|
|
145
|
-
//
|
|
146
|
-
// the
|
|
147
|
-
//
|
|
148
|
-
enabled:
|
|
149
|
-
|
|
113
|
+
// Per-vault effective value: this vault's own override wins; otherwise it
|
|
114
|
+
// inherits the server default; otherwise true. Same ladder as
|
|
115
|
+
// shouldAutoTranscribe, so the admin SPA shows what this vault will do.
|
|
116
|
+
enabled:
|
|
117
|
+
vaultConfig.auto_transcribe?.enabled
|
|
118
|
+
?? globalConfig.auto_transcribe?.enabled
|
|
119
|
+
?? true,
|
|
150
120
|
},
|
|
151
|
-
// Legacy alias mirrors `autoTranscribe.scribeUrl` so hubs reading the
|
|
152
|
-
// pre-vault#353 shape don't regress.
|
|
153
|
-
scribe_url: scribeUrl,
|
|
154
|
-
port: globalConfig.port,
|
|
155
121
|
};
|
|
156
122
|
}
|
|
157
123
|
|
package/src/routes.ts
CHANGED
|
@@ -14,16 +14,19 @@
|
|
|
14
14
|
import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
15
15
|
import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
16
16
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
17
|
-
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
17
|
+
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
18
18
|
import {
|
|
19
19
|
parseContentRange,
|
|
20
20
|
applyContentRange,
|
|
21
21
|
contentRangeRequiresContent,
|
|
22
22
|
type ContentRange,
|
|
23
23
|
} from "../core/src/content-range.ts";
|
|
24
|
-
import { attachValidationStatus } from "../core/src/mcp.ts";
|
|
24
|
+
import { attachValidationStatus, enforceStrictWrite } from "../core/src/mcp.ts";
|
|
25
|
+
import type { ValidationWarning } from "../core/src/schema-defaults.ts";
|
|
26
|
+
import { logStrictBypass } from "./scopes.ts";
|
|
25
27
|
import * as linkOps from "../core/src/links.ts";
|
|
26
28
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
29
|
+
import { IndexedFieldError } from "../core/src/indexed-fields.ts";
|
|
27
30
|
import {
|
|
28
31
|
buildExpandVisibility,
|
|
29
32
|
filterHydratedLinksByTagScope,
|
|
@@ -44,6 +47,52 @@ import { findTokensReferencingTag } from "./token-store.ts";
|
|
|
44
47
|
export type TagScopeCtx = { allowed: Set<string> | null; raw: string[] | null };
|
|
45
48
|
|
|
46
49
|
const NO_TAG_SCOPE: TagScopeCtx = { allowed: null, raw: null };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Write-attribution context (vault#298) for REST writes — the principal
|
|
53
|
+
* (`actor`) and interface (`via`) threaded from the authenticated request into
|
|
54
|
+
* `store.createNote` / `store.updateNote`. Both null for paths without an auth
|
|
55
|
+
* context (the no-op default). See `WriteContext` in core/src/notes.ts.
|
|
56
|
+
*/
|
|
57
|
+
export type WriteCtx = {
|
|
58
|
+
actor: string | null;
|
|
59
|
+
via: string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Migration-bypass (vault#299). True when the caller holds `vault:migrate`
|
|
62
|
+
* — strict-schema enforcement is skipped and the bypass is logged. Defaults
|
|
63
|
+
* to false (full enforcement) for every normal write.
|
|
64
|
+
*/
|
|
65
|
+
bypassStrict?: boolean;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const NO_WRITE_CTX: WriteCtx = { actor: null, via: null };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run the shared strict-schema gate for a REST write (vault#299). Mirrors the
|
|
72
|
+
* MCP path's `enforceStrict` closure: enforce unless the caller holds the
|
|
73
|
+
* migration-bypass scope, and log every bypassed write to the daemon's
|
|
74
|
+
* structured log (the audit-log table, #300, is deferred). Throws
|
|
75
|
+
* `SchemaValidationError` (caught by the route's catch → 422) when not bypassed.
|
|
76
|
+
*/
|
|
77
|
+
function gateStrictWrite(
|
|
78
|
+
store: Store,
|
|
79
|
+
writeCtx: WriteCtx,
|
|
80
|
+
shape: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
|
|
81
|
+
): void {
|
|
82
|
+
enforceStrictWrite(store, shape, {
|
|
83
|
+
bypass: writeCtx.bypassStrict === true,
|
|
84
|
+
onBypass: (violations: ValidationWarning[]) => {
|
|
85
|
+
logStrictBypass({
|
|
86
|
+
actor: writeCtx.actor,
|
|
87
|
+
via: writeCtx.via,
|
|
88
|
+
path: shape.path ?? null,
|
|
89
|
+
tags: shape.tags,
|
|
90
|
+
violations,
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
47
96
|
import {
|
|
48
97
|
expandContent,
|
|
49
98
|
DEFAULT_EXPAND_DEPTH,
|
|
@@ -279,7 +328,7 @@ function parseMetaBrackets(url: URL): {
|
|
|
279
328
|
return {
|
|
280
329
|
error: json(
|
|
281
330
|
{
|
|
282
|
-
error: `bracket-date filter on \`${field}\` supports only \`gte\` (inclusive lower bound) and \`lt\` (exclusive upper bound). Got: \`${op}\`. The dateFilter contract
|
|
331
|
+
error: `bracket-date filter on \`${field}\` supports only \`gte\` (inclusive lower bound) and \`lt\` (exclusive upper bound). Got: \`${op}\`. The dateFilter contract is half-open by design.`,
|
|
283
332
|
code: "INVALID_QUERY",
|
|
284
333
|
},
|
|
285
334
|
400,
|
|
@@ -567,20 +616,20 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
567
616
|
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
568
617
|
extension: parseExtensionFilter(url),
|
|
569
618
|
metadata: bracket.metadata ?? metadataAlias.metadata,
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
619
|
+
// Write-attribution filters (vault#298) — symmetric with the MCP
|
|
620
|
+
// query-notes tool so a REST caller can ask "what did Mathilda write" /
|
|
621
|
+
// "what came in via the meeting-ingest surface" the same way.
|
|
622
|
+
createdBy: parseQuery(url, "created_by") ?? undefined,
|
|
623
|
+
lastUpdatedBy: parseQuery(url, "last_updated_by") ?? undefined,
|
|
624
|
+
createdVia: parseQuery(url, "created_via") ?? undefined,
|
|
625
|
+
lastUpdatedVia: parseQuery(url, "last_updated_via") ?? undefined,
|
|
626
|
+
// Date-range filtering on the query string is bracket-style only:
|
|
627
|
+
// `?meta[created_at][gte]=…&meta[created_at][lt]=…` (see
|
|
628
|
+
// `parseMetaBrackets`). The flat `date_field` / `date_from` / `date_to`
|
|
629
|
+
// params were removed in 0.6.4 (vault#288, breaking) — they are now
|
|
630
|
+
// ignored. The MCP `date_from` / `date_to` shorthand is a separate,
|
|
631
|
+
// supported convenience and is unaffected.
|
|
632
|
+
...(bracket.dateFilter ? { dateFilter: bracket.dateFilter } : {}),
|
|
584
633
|
sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
|
|
585
634
|
orderBy: parseQuery(url, "order_by") ?? undefined,
|
|
586
635
|
limit: parseInt10(parseQuery(url, "limit")) ?? 50,
|
|
@@ -704,9 +753,10 @@ export async function handleNotes(
|
|
|
704
753
|
subpath: string,
|
|
705
754
|
vault?: string,
|
|
706
755
|
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
756
|
+
writeCtx: WriteCtx = NO_WRITE_CTX,
|
|
707
757
|
): Promise<Response> {
|
|
708
758
|
try {
|
|
709
|
-
return await handleNotesInner(req, store, subpath, vault, tagScope);
|
|
759
|
+
return await handleNotesInner(req, store, subpath, vault, tagScope, writeCtx);
|
|
710
760
|
} catch (e: any) {
|
|
711
761
|
const ambig = ambiguousPathResponse(e);
|
|
712
762
|
if (ambig) return ambig;
|
|
@@ -720,6 +770,7 @@ async function handleNotesInner(
|
|
|
720
770
|
subpath: string,
|
|
721
771
|
vault?: string,
|
|
722
772
|
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
773
|
+
writeCtx: WriteCtx = NO_WRITE_CTX,
|
|
723
774
|
): Promise<Response> {
|
|
724
775
|
const url = new URL(req.url);
|
|
725
776
|
const method = req.method;
|
|
@@ -849,7 +900,7 @@ async function handleNotesInner(
|
|
|
849
900
|
|
|
850
901
|
// Structured query
|
|
851
902
|
//
|
|
852
|
-
//
|
|
903
|
+
// Date-range filtering on the query string uses one syntax:
|
|
853
904
|
//
|
|
854
905
|
// - **Bracket-style** (canonical, vault#285 friction point 1.3):
|
|
855
906
|
// `?meta[field][op]=value` / `?meta[created_at][gte]=…`. Exposes
|
|
@@ -857,21 +908,18 @@ async function handleNotesInner(
|
|
|
857
908
|
// not_in/exists) and the dateFilter bridge through one consistent
|
|
858
909
|
// shape. See `parseMetaBrackets` for the grammar.
|
|
859
910
|
//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
//
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
// `meta[created_at][gte]=X` and `
|
|
867
|
-
// the bracket form is the dateFilter the engine sees; the flat
|
|
868
|
-
// params are silently dropped. We don't error — the bracket form is
|
|
869
|
-
// documented as canonical, and rejecting the overlap would block a
|
|
870
|
-
// realistic migration path where a caller half-converted their code.
|
|
911
|
+
// The flat date params (`?date_field=created_at&date_from=…&date_to=…`
|
|
912
|
+
// and the legacy bare `?date_from=…&date_to=…`) were REMOVED in 0.6.4
|
|
913
|
+
// (vault#288, breaking change). They are now silently ignored — a
|
|
914
|
+
// request that passes only flat date params comes back unfiltered.
|
|
915
|
+
// Bracket-style is functionally complete (full operator set), so no
|
|
916
|
+
// capability was lost. Migrate `date_field=created_at&date_from=X` to
|
|
917
|
+
// `meta[created_at][gte]=X` (and `date_to=Y` to `meta[created_at][lt]=Y`).
|
|
871
918
|
//
|
|
872
919
|
// Surface asymmetry: REST flattens to a query string; MCP takes a
|
|
873
|
-
// nested `date_filter: { field, from, to }` object directly
|
|
874
|
-
//
|
|
920
|
+
// nested `date_filter: { field, from, to }` object directly (plus a
|
|
921
|
+
// top-level `date_from` / `date_to` shorthand, which is unaffected by
|
|
922
|
+
// this removal). Both lower to the same store-level `dateFilter` shape.
|
|
875
923
|
// Structured-query parsing is shared with the live `/subscribe` route
|
|
876
924
|
// (see `parseNotesQueryOpts`) so both endpoints lower an identical query
|
|
877
925
|
// string to the same `QueryOpts` — predicate parity by construction.
|
|
@@ -1102,6 +1150,13 @@ async function handleNotesInner(
|
|
|
1102
1150
|
const extension = item.extension !== undefined
|
|
1103
1151
|
? validateExtension(item.extension)
|
|
1104
1152
|
: undefined;
|
|
1153
|
+
// Strict-schema gate (vault#299) — reject before any write so a
|
|
1154
|
+
// mid-batch violation rolls back via the outer BEGIN/ROLLBACK.
|
|
1155
|
+
gateStrictWrite(store, writeCtx, {
|
|
1156
|
+
path: item.path,
|
|
1157
|
+
tags: item.tags,
|
|
1158
|
+
metadata: item.metadata,
|
|
1159
|
+
});
|
|
1105
1160
|
const note = await store.createNote(item.content ?? "", {
|
|
1106
1161
|
id: item.id,
|
|
1107
1162
|
path: item.path,
|
|
@@ -1109,6 +1164,9 @@ async function handleNotesInner(
|
|
|
1109
1164
|
metadata: item.metadata,
|
|
1110
1165
|
created_at: item.createdAt ?? item.created_at,
|
|
1111
1166
|
...(extension !== undefined ? { extension } : {}),
|
|
1167
|
+
// Write-attribution (vault#298) — REST batch create.
|
|
1168
|
+
actor: writeCtx.actor,
|
|
1169
|
+
via: writeCtx.via,
|
|
1112
1170
|
});
|
|
1113
1171
|
|
|
1114
1172
|
// Create explicit links
|
|
@@ -1131,6 +1189,13 @@ async function handleNotesInner(
|
|
|
1131
1189
|
409,
|
|
1132
1190
|
);
|
|
1133
1191
|
}
|
|
1192
|
+
// Strict-schema rejection (vault#299 Part A) on create — 422.
|
|
1193
|
+
if (e && e.code === "SCHEMA_VALIDATION") {
|
|
1194
|
+
return json(
|
|
1195
|
+
{ error_type: "schema_validation", error: "schema_validation", violations: e.violations ?? [], message: e.message },
|
|
1196
|
+
422,
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1134
1199
|
if (e && e.code === "INVALID_EXTENSION") {
|
|
1135
1200
|
return json(
|
|
1136
1201
|
{ error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
|
|
@@ -1383,8 +1448,17 @@ async function handleNotesInner(
|
|
|
1383
1448
|
...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
|
|
1384
1449
|
...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
|
|
1385
1450
|
...(createExt !== undefined ? { extension: createExt } : {}),
|
|
1451
|
+
// Write-attribution (vault#298) — REST upsert-create branch.
|
|
1452
|
+
actor: writeCtx.actor,
|
|
1453
|
+
via: writeCtx.via,
|
|
1386
1454
|
};
|
|
1387
1455
|
const content = (body.content as string | undefined) ?? "";
|
|
1456
|
+
// Strict-schema gate (vault#299) — if_missing:"create" is a create.
|
|
1457
|
+
gateStrictWrite(store, writeCtx, {
|
|
1458
|
+
path: createOpts.path ?? undefined,
|
|
1459
|
+
tags: createOpts.tags,
|
|
1460
|
+
metadata: createOpts.metadata,
|
|
1461
|
+
});
|
|
1388
1462
|
const created = await store.createNote(content, createOpts);
|
|
1389
1463
|
if (tagsArr.length > 0) {
|
|
1390
1464
|
await applySchemaDefaults(store, db, [created.id], tagsArr);
|
|
@@ -1474,7 +1548,19 @@ async function handleNotesInner(
|
|
|
1474
1548
|
&& body.createdAt === undefined
|
|
1475
1549
|
&& body.tags === undefined
|
|
1476
1550
|
&& body.links === undefined;
|
|
1477
|
-
|
|
1551
|
+
// A state_transition is itself a compare-and-set precondition (vault#299
|
|
1552
|
+
// Part B) — a transition-only PATCH needs no if_updated_at/force.
|
|
1553
|
+
const isTransitionOnly = body.state_transition !== undefined
|
|
1554
|
+
&& !hasContent
|
|
1555
|
+
&& !hasAppendPrepend
|
|
1556
|
+
&& !hasContentEdit
|
|
1557
|
+
&& body.path === undefined
|
|
1558
|
+
&& body.metadata === undefined
|
|
1559
|
+
&& body.created_at === undefined
|
|
1560
|
+
&& body.createdAt === undefined
|
|
1561
|
+
&& body.tags === undefined
|
|
1562
|
+
&& body.links === undefined;
|
|
1563
|
+
if (!isAppendOnly && !isTransitionOnly && body.if_updated_at === undefined && body.force !== true) {
|
|
1478
1564
|
return json(
|
|
1479
1565
|
{
|
|
1480
1566
|
error_type: "precondition_required",
|
|
@@ -1557,8 +1643,11 @@ async function handleNotesInner(
|
|
|
1557
1643
|
updates.extension = validateExtension(body.extension);
|
|
1558
1644
|
}
|
|
1559
1645
|
if (body.metadata !== undefined) {
|
|
1560
|
-
|
|
1561
|
-
updates.metadata =
|
|
1646
|
+
// RFC 7386 merge: incoming `null` removes the key (vault#478/#479).
|
|
1647
|
+
updates.metadata = mergeMetadata(
|
|
1648
|
+
note.metadata as Record<string, unknown> | null | undefined,
|
|
1649
|
+
body.metadata as Record<string, unknown>,
|
|
1650
|
+
);
|
|
1562
1651
|
}
|
|
1563
1652
|
if (body.created_at !== undefined || body.createdAt !== undefined) {
|
|
1564
1653
|
updates.created_at = body.created_at ?? body.createdAt;
|
|
@@ -1566,8 +1655,39 @@ async function handleNotesInner(
|
|
|
1566
1655
|
if (body.if_updated_at !== undefined) {
|
|
1567
1656
|
updates.if_updated_at = body.if_updated_at;
|
|
1568
1657
|
}
|
|
1658
|
+
// Compare-and-set state transition (vault#299 Part B). Combinable with
|
|
1659
|
+
// other field updates; folds into the same atomic UPDATE.
|
|
1660
|
+
const stBody = body.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
|
|
1661
|
+
if (stBody !== undefined) {
|
|
1662
|
+
if (typeof stBody.field !== "string" || stBody.field.length === 0) {
|
|
1663
|
+
return json(
|
|
1664
|
+
{ error: "bad_request", message: "`state_transition.field` must be a non-empty string." },
|
|
1665
|
+
400,
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
updates.state_transition = { field: stBody.field, from: stBody.from, to: stBody.to };
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// --- Strict-schema gate (vault#299 Part A) ---
|
|
1672
|
+
// Validate the PROSPECTIVE shape (final tags + merged metadata, incl. a
|
|
1673
|
+
// state_transition's `to`) before the write so a rejection leaves the
|
|
1674
|
+
// note untouched. Throws SchemaValidationError → 422 in the catch.
|
|
1675
|
+
{
|
|
1676
|
+
const removeSet = new Set<string>((body.tags?.remove as string[] | undefined) ?? []);
|
|
1677
|
+
const projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
|
|
1678
|
+
for (const t of (body.tags?.add as string[] | undefined) ?? []) projectedTags.add(t);
|
|
1679
|
+
const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
|
|
1680
|
+
const projectedMeta = stBody !== undefined
|
|
1681
|
+
? { ...baseMeta, [stBody.field as string]: stBody.to }
|
|
1682
|
+
: baseMeta;
|
|
1683
|
+
gateStrictWrite(store, writeCtx, { path: note.path, tags: [...projectedTags], metadata: projectedMeta });
|
|
1684
|
+
}
|
|
1569
1685
|
|
|
1570
1686
|
if (Object.keys(updates).length > 0) {
|
|
1687
|
+
// Write-attribution (vault#298) — REST update. Stamp the most-recent-
|
|
1688
|
+
// write columns on the same UPDATE that bumps updated_at.
|
|
1689
|
+
updates.actor = writeCtx.actor;
|
|
1690
|
+
updates.via = writeCtx.via;
|
|
1571
1691
|
await store.updateNote(note.id, updates);
|
|
1572
1692
|
}
|
|
1573
1693
|
|
|
@@ -1661,6 +1781,39 @@ async function handleNotesInner(
|
|
|
1661
1781
|
409,
|
|
1662
1782
|
);
|
|
1663
1783
|
}
|
|
1784
|
+
// State-transition compare-and-set conflict (vault#299 Part B). A
|
|
1785
|
+
// DISTINCT error vocabulary from `conflict` (settled lead #3): the
|
|
1786
|
+
// field VALUE didn't match `from`, not the updated_at token. 409.
|
|
1787
|
+
if (e && e.code === "TRANSITION_CONFLICT") {
|
|
1788
|
+
return json(
|
|
1789
|
+
{
|
|
1790
|
+
error_type: "transition_conflict",
|
|
1791
|
+
error: "transition_conflict",
|
|
1792
|
+
note_id: e.note_id,
|
|
1793
|
+
path: e.note_path ?? null,
|
|
1794
|
+
field: e.field,
|
|
1795
|
+
expected_from: e.expected_from,
|
|
1796
|
+
to: e.to,
|
|
1797
|
+
current: e.current ?? null,
|
|
1798
|
+
message: e.message,
|
|
1799
|
+
},
|
|
1800
|
+
409,
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
// Strict-schema rejection (vault#299 Part A). One error carrying ALL
|
|
1804
|
+
// per-field violations (settled lead #1). 422 Unprocessable Entity —
|
|
1805
|
+
// the note exists / request is well-formed but violates the contract.
|
|
1806
|
+
if (e && e.code === "SCHEMA_VALIDATION") {
|
|
1807
|
+
return json(
|
|
1808
|
+
{
|
|
1809
|
+
error_type: "schema_validation",
|
|
1810
|
+
error: "schema_validation",
|
|
1811
|
+
violations: e.violations ?? [],
|
|
1812
|
+
message: e.message,
|
|
1813
|
+
},
|
|
1814
|
+
422,
|
|
1815
|
+
);
|
|
1816
|
+
}
|
|
1664
1817
|
// Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
|
|
1665
1818
|
if (e && e.code === "PATH_CONFLICT") {
|
|
1666
1819
|
return json(
|
|
@@ -1934,12 +2087,27 @@ export async function handleTags(
|
|
|
1934
2087
|
fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
|
|
1935
2088
|
}
|
|
1936
2089
|
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
2090
|
+
// A bad indexed-field name (or an unindexable type, or a cross-tag type
|
|
2091
|
+
// mismatch) is a CLIENT error — return 400, not the catch-all 500. The
|
|
2092
|
+
// store pre-validates and is transactional, so the schema is left
|
|
2093
|
+
// unchanged on failure (no orphan/lying index). vault#478.
|
|
2094
|
+
let result;
|
|
2095
|
+
try {
|
|
2096
|
+
result = await store.upsertTagRecord(tagName, {
|
|
2097
|
+
...(body.description !== undefined ? { description: body.description } : {}),
|
|
2098
|
+
...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
|
|
2099
|
+
...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
|
|
2100
|
+
...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
|
|
2101
|
+
});
|
|
2102
|
+
} catch (err) {
|
|
2103
|
+
if (err instanceof IndexedFieldError) {
|
|
2104
|
+
return json(
|
|
2105
|
+
{ error: err.message, error_type: "invalid_indexed_field" },
|
|
2106
|
+
400,
|
|
2107
|
+
);
|
|
2108
|
+
}
|
|
2109
|
+
throw err;
|
|
2110
|
+
}
|
|
1943
2111
|
return json(result);
|
|
1944
2112
|
}
|
|
1945
2113
|
|