@gmickel/gno 1.24.0 → 1.26.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 +18 -4
- package/assets/skill/SKILL.md +40 -17
- package/assets/skill/recipes/capture-and-file.md +20 -5
- package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +1 -0
- package/browser-extension/dist/PRIVACY.md +55 -0
- package/browser-extension/dist/chunk-vn5f663b.js +50 -0
- package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
- package/browser-extension/dist/content.js +1 -0
- package/browser-extension/dist/manifest.json +25 -0
- package/browser-extension/dist/preview.html +13 -0
- package/browser-extension/dist/service-worker.js +40 -0
- package/package.json +13 -3
- package/spec/cli.md +196 -4
- package/spec/db/schema.sql +102 -1
- package/spec/mcp.md +10 -0
- package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
- package/spec/output-schemas/browser-clip.schema.json +586 -0
- package/spec/output-schemas/capture-receipt.schema.json +22 -1
- package/spec/output-schemas/clipper-csrf.schema.json +12 -0
- package/spec/output-schemas/clipper-error.schema.json +46 -0
- package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
- package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
- package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
- package/spec/output-schemas/clipper-revoke.schema.json +28 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
- package/spec/output-schemas/project-profile-apply.schema.json +209 -0
- package/spec/output-schemas/project-profile-command.schema.json +160 -0
- package/spec/output-schemas/query-diagnose.schema.json +7 -1
- package/spec/output-schemas/setup-profile-result.schema.json +87 -0
- package/spec/project-profile.schema.json +301 -0
- package/src/cli/commands/collection/add.ts +39 -45
- package/src/cli/commands/collection/remove.ts +28 -28
- package/src/cli/commands/collection/rename.ts +55 -73
- package/src/cli/commands/context/add.ts +37 -26
- package/src/cli/commands/context/rm.ts +47 -20
- package/src/cli/commands/init.ts +55 -125
- package/src/cli/commands/models/use.ts +43 -38
- package/src/cli/commands/profile-apply.ts +334 -0
- package/src/cli/commands/profile.ts +409 -0
- package/src/cli/commands/setup-activation.ts +205 -54
- package/src/cli/commands/setup-profile.ts +223 -0
- package/src/cli/commands/setup.ts +3 -0
- package/src/cli/program.ts +110 -9
- package/src/config/index.ts +3 -0
- package/src/config/project-profile.ts +367 -0
- package/src/config/saver.ts +16 -7
- package/src/config/types.ts +42 -0
- package/src/core/browser-clip-provenance.ts +139 -0
- package/src/core/browser-clip.ts +473 -0
- package/src/core/capture-write.ts +5 -0
- package/src/core/capture.ts +75 -18
- package/src/core/config-mutation.ts +138 -76
- package/src/core/config-write-lock.ts +89 -0
- package/src/core/context-identity.ts +16 -0
- package/src/core/context-resolver.ts +2 -12
- package/src/core/file-lock.ts +20 -6
- package/src/core/folder-setup-planning.ts +6 -21
- package/src/core/folder-setup.ts +30 -2
- package/src/core/path-rules.ts +53 -0
- package/src/core/project-affinity-surface.ts +102 -7
- package/src/core/project-profile-apply-state.ts +268 -0
- package/src/core/project-profile-apply-validation.ts +95 -0
- package/src/core/project-profile-apply.ts +408 -0
- package/src/core/project-profile-canonical.ts +71 -0
- package/src/core/project-profile-diff.ts +302 -0
- package/src/core/project-profile-discovery.ts +519 -0
- package/src/core/project-profile-file.ts +37 -0
- package/src/core/project-profile-parser.ts +98 -0
- package/src/core/project-profile.ts +490 -0
- package/src/ingestion/walker.ts +85 -44
- package/src/llm/cache.ts +21 -0
- package/src/serve/capture-service.ts +420 -0
- package/src/serve/clipper-body.ts +62 -0
- package/src/serve/clipper-capture.ts +248 -0
- package/src/serve/clipper-contract.ts +57 -0
- package/src/serve/clipper-idempotency.ts +35 -0
- package/src/serve/clipper-pairing.ts +297 -0
- package/src/serve/clipper-security-errors.ts +23 -0
- package/src/serve/clipper-security.ts +449 -0
- package/src/serve/config-sync.ts +2 -2
- package/src/serve/public/app.tsx +8 -1
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/index.html +1 -0
- package/src/serve/public/lib/clipper-approval.ts +206 -0
- package/src/serve/public/pages/ClipperPairing.tsx +210 -0
- package/src/serve/resident-runtime.ts +1 -0
- package/src/serve/routes/api.ts +20 -115
- package/src/serve/routes/clipper.ts +394 -0
- package/src/serve/server.ts +22 -0
- package/src/store/migrations/020-browser-clipper-security.ts +128 -0
- package/src/store/migrations/021-multi-context-identity.ts +37 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +83 -0
- package/src/store/sqlite/clipper-store-types.ts +104 -0
- package/src/store/sqlite/clipper-store.ts +496 -0
|
@@ -625,6 +625,57 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
625
625
|
}
|
|
626
626
|
}
|
|
627
627
|
|
|
628
|
+
async upsertCollections(
|
|
629
|
+
collections: Collection[]
|
|
630
|
+
): Promise<StoreResult<void>> {
|
|
631
|
+
try {
|
|
632
|
+
const db = this.ensureOpen();
|
|
633
|
+
const stmt = db.prepare(`
|
|
634
|
+
INSERT INTO collections (name, path, pattern, include, exclude, update_cmd, language_hint, synced_at)
|
|
635
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
636
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
637
|
+
path = excluded.path,
|
|
638
|
+
pattern = excluded.pattern,
|
|
639
|
+
include = excluded.include,
|
|
640
|
+
exclude = excluded.exclude,
|
|
641
|
+
update_cmd = excluded.update_cmd,
|
|
642
|
+
language_hint = excluded.language_hint,
|
|
643
|
+
synced_at = datetime('now')
|
|
644
|
+
WHERE collections.path IS NOT excluded.path
|
|
645
|
+
OR collections.pattern IS NOT excluded.pattern
|
|
646
|
+
OR collections.include IS NOT excluded.include
|
|
647
|
+
OR collections.exclude IS NOT excluded.exclude
|
|
648
|
+
OR collections.update_cmd IS NOT excluded.update_cmd
|
|
649
|
+
OR collections.language_hint IS NOT excluded.language_hint
|
|
650
|
+
`);
|
|
651
|
+
const transaction = db.transaction(() => {
|
|
652
|
+
for (const collection of collections) {
|
|
653
|
+
stmt.run(
|
|
654
|
+
collection.name,
|
|
655
|
+
collection.path,
|
|
656
|
+
collection.pattern,
|
|
657
|
+
collection.include.length > 0
|
|
658
|
+
? JSON.stringify(collection.include)
|
|
659
|
+
: null,
|
|
660
|
+
collection.exclude.length > 0
|
|
661
|
+
? JSON.stringify(collection.exclude)
|
|
662
|
+
: null,
|
|
663
|
+
collection.updateCmd ?? null,
|
|
664
|
+
collection.languageHint ?? null
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
transaction();
|
|
669
|
+
return ok(undefined);
|
|
670
|
+
} catch (cause) {
|
|
671
|
+
return err(
|
|
672
|
+
"QUERY_FAILED",
|
|
673
|
+
cause instanceof Error ? cause.message : "Failed to upsert collections",
|
|
674
|
+
cause
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
628
679
|
async syncContexts(contexts: Context[]): Promise<StoreResult<void>> {
|
|
629
680
|
try {
|
|
630
681
|
const db = this.ensureOpen();
|
|
@@ -636,6 +687,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
636
687
|
const stmt = db.prepare(`
|
|
637
688
|
INSERT INTO contexts (scope_type, scope_key, text, synced_at)
|
|
638
689
|
VALUES (?, ?, ?, datetime('now'))
|
|
690
|
+
ON CONFLICT(scope_type, scope_key, text) DO UPDATE SET
|
|
691
|
+
synced_at = excluded.synced_at
|
|
639
692
|
`);
|
|
640
693
|
|
|
641
694
|
for (const c of contexts) {
|
|
@@ -655,6 +708,36 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
655
708
|
}
|
|
656
709
|
}
|
|
657
710
|
|
|
711
|
+
async upsertContexts(contexts: Context[]): Promise<StoreResult<void>> {
|
|
712
|
+
try {
|
|
713
|
+
const db = this.ensureOpen();
|
|
714
|
+
const stmt = db.prepare(`
|
|
715
|
+
INSERT INTO contexts (scope_type, scope_key, text, synced_at)
|
|
716
|
+
VALUES (?, ?, ?, datetime('now'))
|
|
717
|
+
ON CONFLICT(scope_type, scope_key, text) DO NOTHING
|
|
718
|
+
`);
|
|
719
|
+
let inserted = 0;
|
|
720
|
+
const transaction = db.transaction(() => {
|
|
721
|
+
for (const context of contexts) {
|
|
722
|
+
inserted += stmt.run(
|
|
723
|
+
context.scopeType,
|
|
724
|
+
context.scopeKey,
|
|
725
|
+
context.text
|
|
726
|
+
).changes;
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
transaction();
|
|
730
|
+
if (inserted > 0) this.contextGeneration += 1;
|
|
731
|
+
return ok(undefined);
|
|
732
|
+
} catch (cause) {
|
|
733
|
+
return err(
|
|
734
|
+
"QUERY_FAILED",
|
|
735
|
+
cause instanceof Error ? cause.message : "Failed to upsert contexts",
|
|
736
|
+
cause
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
658
741
|
async getCollections(): Promise<StoreResult<CollectionRow[]>> {
|
|
659
742
|
try {
|
|
660
743
|
const db = this.ensureOpen();
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Public contracts for browser-clipper SQLite persistence. */
|
|
2
|
+
|
|
3
|
+
export interface ClipperGrantInput {
|
|
4
|
+
id: string;
|
|
5
|
+
tokenHash: string;
|
|
6
|
+
origin: string;
|
|
7
|
+
createdAtMs: number;
|
|
8
|
+
expiresAtMs: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ClipperGrant {
|
|
12
|
+
id: string;
|
|
13
|
+
origin: string;
|
|
14
|
+
scope: "capture";
|
|
15
|
+
createdAtMs: number;
|
|
16
|
+
expiresAtMs: number;
|
|
17
|
+
revokedAtMs: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CreateClipperGrantResult =
|
|
21
|
+
| { status: "created"; grant: ClipperGrant }
|
|
22
|
+
| { status: "duplicate"; grant: ClipperGrant }
|
|
23
|
+
| { status: "conflict" };
|
|
24
|
+
|
|
25
|
+
export type AuthorizeClipperGrantResult =
|
|
26
|
+
| { status: "authorized"; grant: ClipperGrant }
|
|
27
|
+
| { status: "unauthorized" }
|
|
28
|
+
| { status: "expired" }
|
|
29
|
+
| { status: "revoked" };
|
|
30
|
+
|
|
31
|
+
export type RevokeClipperGrantResult =
|
|
32
|
+
| { status: "revoked"; grant: ClipperGrant }
|
|
33
|
+
| { status: "already_revoked"; grant: ClipperGrant }
|
|
34
|
+
| { status: "expired"; grant: ClipperGrant }
|
|
35
|
+
| { status: "not_found" };
|
|
36
|
+
|
|
37
|
+
export type ClipperCollisionPolicyResult =
|
|
38
|
+
| "created"
|
|
39
|
+
| "opened_existing"
|
|
40
|
+
| "created_with_suffix"
|
|
41
|
+
| "overwritten"
|
|
42
|
+
| "conflict";
|
|
43
|
+
|
|
44
|
+
export interface ClipperIdempotencyPlan {
|
|
45
|
+
collection: string;
|
|
46
|
+
relPath: string;
|
|
47
|
+
collisionPolicyResult: ClipperCollisionPolicyResult;
|
|
48
|
+
contentHash: string;
|
|
49
|
+
clipIdentity: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ClaimClipperIdempotencyInput {
|
|
53
|
+
grantId: string;
|
|
54
|
+
keyHash: string;
|
|
55
|
+
requestDigest: string;
|
|
56
|
+
plan: ClipperIdempotencyPlan;
|
|
57
|
+
nowMs: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CompleteClipperIdempotencyInput {
|
|
61
|
+
grantId: string;
|
|
62
|
+
keyHash: string;
|
|
63
|
+
requestDigest: string;
|
|
64
|
+
responseJson: string;
|
|
65
|
+
statusCode: number;
|
|
66
|
+
nowMs: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface InspectClipperIdempotencyInput {
|
|
70
|
+
grantId: string;
|
|
71
|
+
keyHash: string;
|
|
72
|
+
requestDigest: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ClipperIdempotencyPending {
|
|
76
|
+
requestDigest: string;
|
|
77
|
+
plan: ClipperIdempotencyPlan;
|
|
78
|
+
createdAtMs: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ClipperIdempotencyReplay extends ClipperIdempotencyPending {
|
|
82
|
+
statusCode: number;
|
|
83
|
+
responseJson: string;
|
|
84
|
+
completedAtMs: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type ClaimClipperIdempotencyResult =
|
|
88
|
+
| { status: "claimed" }
|
|
89
|
+
| { status: "pending"; pending: ClipperIdempotencyPending }
|
|
90
|
+
| { status: "replay"; replay: ClipperIdempotencyReplay }
|
|
91
|
+
| { status: "conflict" }
|
|
92
|
+
| { status: "grant_inactive" };
|
|
93
|
+
|
|
94
|
+
export type InspectClipperIdempotencyResult =
|
|
95
|
+
| { status: "pending"; pending: ClipperIdempotencyPending }
|
|
96
|
+
| { status: "replay"; replay: ClipperIdempotencyReplay }
|
|
97
|
+
| { status: "conflict" }
|
|
98
|
+
| { status: "not_found" };
|
|
99
|
+
|
|
100
|
+
export type CompleteClipperIdempotencyResult =
|
|
101
|
+
| { status: "completed"; replay: ClipperIdempotencyReplay }
|
|
102
|
+
| { status: "replay"; replay: ClipperIdempotencyReplay }
|
|
103
|
+
| { status: "conflict" }
|
|
104
|
+
| { status: "not_found" };
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/** SQLite persistence primitives for the loopback browser clipper. */
|
|
2
|
+
|
|
3
|
+
import type { Database } from "bun:sqlite";
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
AuthorizeClipperGrantResult,
|
|
7
|
+
ClaimClipperIdempotencyInput,
|
|
8
|
+
ClaimClipperIdempotencyResult,
|
|
9
|
+
ClipperCollisionPolicyResult,
|
|
10
|
+
ClipperGrant,
|
|
11
|
+
ClipperGrantInput,
|
|
12
|
+
ClipperIdempotencyPending,
|
|
13
|
+
ClipperIdempotencyPlan,
|
|
14
|
+
ClipperIdempotencyReplay,
|
|
15
|
+
CompleteClipperIdempotencyInput,
|
|
16
|
+
CompleteClipperIdempotencyResult,
|
|
17
|
+
CreateClipperGrantResult,
|
|
18
|
+
InspectClipperIdempotencyInput,
|
|
19
|
+
InspectClipperIdempotencyResult,
|
|
20
|
+
RevokeClipperGrantResult,
|
|
21
|
+
} from "./clipper-store-types";
|
|
22
|
+
|
|
23
|
+
export type {
|
|
24
|
+
AuthorizeClipperGrantResult,
|
|
25
|
+
ClaimClipperIdempotencyInput,
|
|
26
|
+
ClaimClipperIdempotencyResult,
|
|
27
|
+
ClipperCollisionPolicyResult,
|
|
28
|
+
ClipperGrant,
|
|
29
|
+
ClipperGrantInput,
|
|
30
|
+
ClipperIdempotencyPending,
|
|
31
|
+
ClipperIdempotencyPlan,
|
|
32
|
+
ClipperIdempotencyReplay,
|
|
33
|
+
CompleteClipperIdempotencyInput,
|
|
34
|
+
CompleteClipperIdempotencyResult,
|
|
35
|
+
CreateClipperGrantResult,
|
|
36
|
+
InspectClipperIdempotencyInput,
|
|
37
|
+
InspectClipperIdempotencyResult,
|
|
38
|
+
RevokeClipperGrantResult,
|
|
39
|
+
} from "./clipper-store-types";
|
|
40
|
+
|
|
41
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
42
|
+
const EXTENSION_ORIGIN_PATTERN = /^chrome-extension:\/\/[a-p]{32}$/u;
|
|
43
|
+
const MAX_IDENTIFIER_LENGTH = 128;
|
|
44
|
+
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
45
|
+
|
|
46
|
+
interface DbClipperGrantRow {
|
|
47
|
+
id: string;
|
|
48
|
+
token_hash: string;
|
|
49
|
+
origin: string;
|
|
50
|
+
scope: "capture";
|
|
51
|
+
created_at_ms: number;
|
|
52
|
+
expires_at_ms: number;
|
|
53
|
+
revoked_at_ms: number | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface DbClipperIdempotencyRow {
|
|
57
|
+
key_hash: string;
|
|
58
|
+
request_digest: string;
|
|
59
|
+
collection: string;
|
|
60
|
+
rel_path: string;
|
|
61
|
+
collision_policy_result: ClipperCollisionPolicyResult;
|
|
62
|
+
content_hash: string;
|
|
63
|
+
clip_identity: string;
|
|
64
|
+
state: "pending" | "completed";
|
|
65
|
+
status_code: number | null;
|
|
66
|
+
response_json: string | null;
|
|
67
|
+
created_at_ms: number;
|
|
68
|
+
completed_at_ms: number | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const assertIdentifier = (value: string, label: string): void => {
|
|
72
|
+
if (value.length < 1 || value.length > MAX_IDENTIFIER_LENGTH) {
|
|
73
|
+
throw new RangeError(
|
|
74
|
+
`${label} must contain between 1 and ${MAX_IDENTIFIER_LENGTH} characters`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const assertSha256 = (value: string, label: string): void => {
|
|
80
|
+
if (!SHA256_PATTERN.test(value)) {
|
|
81
|
+
throw new TypeError(`${label} must be a lowercase SHA-256 digest`);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const assertTimestamp = (value: number, label: string): void => {
|
|
86
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
87
|
+
throw new RangeError(`${label} must be a non-negative safe integer`);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const mapGrant = (row: DbClipperGrantRow): ClipperGrant => ({
|
|
92
|
+
id: row.id,
|
|
93
|
+
origin: row.origin,
|
|
94
|
+
scope: row.scope,
|
|
95
|
+
createdAtMs: row.created_at_ms,
|
|
96
|
+
expiresAtMs: row.expires_at_ms,
|
|
97
|
+
revokedAtMs: row.revoked_at_ms,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const getGrantByIdentity = (
|
|
101
|
+
db: Database,
|
|
102
|
+
id: string,
|
|
103
|
+
tokenHash: string
|
|
104
|
+
): DbClipperGrantRow | null =>
|
|
105
|
+
db
|
|
106
|
+
.query<DbClipperGrantRow, [string, string]>(
|
|
107
|
+
`SELECT id, token_hash, origin, scope, created_at_ms, expires_at_ms,
|
|
108
|
+
revoked_at_ms
|
|
109
|
+
FROM clipper_grants
|
|
110
|
+
WHERE id = ? OR token_hash = ?
|
|
111
|
+
LIMIT 1`
|
|
112
|
+
)
|
|
113
|
+
.get(id, tokenHash) ?? null;
|
|
114
|
+
|
|
115
|
+
const getGrantById = (db: Database, id: string): DbClipperGrantRow | null =>
|
|
116
|
+
db
|
|
117
|
+
.query<DbClipperGrantRow, [string]>(
|
|
118
|
+
`SELECT id, token_hash, origin, scope, created_at_ms, expires_at_ms,
|
|
119
|
+
revoked_at_ms
|
|
120
|
+
FROM clipper_grants
|
|
121
|
+
WHERE id = ?`
|
|
122
|
+
)
|
|
123
|
+
.get(id) ?? null;
|
|
124
|
+
|
|
125
|
+
const getIdempotencyRow = (
|
|
126
|
+
db: Database,
|
|
127
|
+
grantId: string,
|
|
128
|
+
keyHash: string
|
|
129
|
+
): DbClipperIdempotencyRow | null =>
|
|
130
|
+
db
|
|
131
|
+
.query<DbClipperIdempotencyRow, [string, string]>(
|
|
132
|
+
`SELECT key_hash, request_digest, collection, rel_path,
|
|
133
|
+
collision_policy_result, content_hash, clip_identity, state,
|
|
134
|
+
status_code, response_json, created_at_ms, completed_at_ms
|
|
135
|
+
FROM clipper_capture_idempotency
|
|
136
|
+
WHERE grant_id = ? AND key_hash = ?`
|
|
137
|
+
)
|
|
138
|
+
.get(grantId, keyHash) ?? null;
|
|
139
|
+
|
|
140
|
+
const getIdempotencyRowByDigest = (
|
|
141
|
+
db: Database,
|
|
142
|
+
grantId: string,
|
|
143
|
+
requestDigest: string
|
|
144
|
+
): DbClipperIdempotencyRow | null =>
|
|
145
|
+
db
|
|
146
|
+
.query<DbClipperIdempotencyRow, [string, string]>(
|
|
147
|
+
`SELECT key_hash, request_digest, collection, rel_path,
|
|
148
|
+
collision_policy_result, content_hash, clip_identity, state,
|
|
149
|
+
status_code, response_json, created_at_ms, completed_at_ms
|
|
150
|
+
FROM clipper_capture_idempotency
|
|
151
|
+
WHERE grant_id = ? AND request_digest = ?`
|
|
152
|
+
)
|
|
153
|
+
.get(grantId, requestDigest) ?? null;
|
|
154
|
+
|
|
155
|
+
const planFromRow = (row: DbClipperIdempotencyRow): ClipperIdempotencyPlan => ({
|
|
156
|
+
collection: row.collection,
|
|
157
|
+
relPath: row.rel_path,
|
|
158
|
+
collisionPolicyResult: row.collision_policy_result,
|
|
159
|
+
contentHash: row.content_hash,
|
|
160
|
+
clipIdentity: row.clip_identity,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const pendingFromRow = (
|
|
164
|
+
row: DbClipperIdempotencyRow
|
|
165
|
+
): ClipperIdempotencyPending => ({
|
|
166
|
+
requestDigest: row.request_digest,
|
|
167
|
+
plan: planFromRow(row),
|
|
168
|
+
createdAtMs: row.created_at_ms,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const replayFromRow = (
|
|
172
|
+
row: DbClipperIdempotencyRow
|
|
173
|
+
): ClipperIdempotencyReplay | null => {
|
|
174
|
+
if (
|
|
175
|
+
row.state !== "completed" ||
|
|
176
|
+
row.status_code === null ||
|
|
177
|
+
row.response_json === null ||
|
|
178
|
+
row.completed_at_ms === null
|
|
179
|
+
) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
...pendingFromRow(row),
|
|
184
|
+
statusCode: row.status_code,
|
|
185
|
+
responseJson: row.response_json,
|
|
186
|
+
completedAtMs: row.completed_at_ms,
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const idempotencyResultFromRow = (
|
|
191
|
+
row: DbClipperIdempotencyRow
|
|
192
|
+
):
|
|
193
|
+
| { status: "pending"; pending: ClipperIdempotencyPending }
|
|
194
|
+
| { status: "replay"; replay: ClipperIdempotencyReplay } => {
|
|
195
|
+
const replay = replayFromRow(row);
|
|
196
|
+
return replay
|
|
197
|
+
? { status: "replay", replay }
|
|
198
|
+
: { status: "pending", pending: pendingFromRow(row) };
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const assertPlan = (plan: ClipperIdempotencyPlan): void => {
|
|
202
|
+
if (
|
|
203
|
+
plan.collection.length < 1 ||
|
|
204
|
+
new TextEncoder().encode(plan.collection).byteLength > 256
|
|
205
|
+
) {
|
|
206
|
+
throw new RangeError("Clipper plan collection is empty or too long");
|
|
207
|
+
}
|
|
208
|
+
if (
|
|
209
|
+
plan.relPath.length < 1 ||
|
|
210
|
+
new TextEncoder().encode(plan.relPath).byteLength > 8192
|
|
211
|
+
) {
|
|
212
|
+
throw new RangeError("Clipper plan relative path is empty or too long");
|
|
213
|
+
}
|
|
214
|
+
if (
|
|
215
|
+
![
|
|
216
|
+
"created",
|
|
217
|
+
"opened_existing",
|
|
218
|
+
"created_with_suffix",
|
|
219
|
+
"overwritten",
|
|
220
|
+
"conflict",
|
|
221
|
+
].includes(plan.collisionPolicyResult)
|
|
222
|
+
) {
|
|
223
|
+
throw new TypeError("Clipper plan has an invalid collision result");
|
|
224
|
+
}
|
|
225
|
+
assertSha256(plan.contentHash, "Clipper plan content hash");
|
|
226
|
+
assertSha256(plan.clipIdentity, "Clipper plan identity");
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const plansMatch = (
|
|
230
|
+
left: ClipperIdempotencyPlan,
|
|
231
|
+
right: ClipperIdempotencyPlan
|
|
232
|
+
): boolean =>
|
|
233
|
+
left.collection === right.collection &&
|
|
234
|
+
left.relPath === right.relPath &&
|
|
235
|
+
left.collisionPolicyResult === right.collisionPolicyResult &&
|
|
236
|
+
left.contentHash === right.contentHash &&
|
|
237
|
+
left.clipIdentity === right.clipIdentity;
|
|
238
|
+
|
|
239
|
+
export const createClipperGrant = (
|
|
240
|
+
db: Database,
|
|
241
|
+
input: ClipperGrantInput
|
|
242
|
+
): CreateClipperGrantResult => {
|
|
243
|
+
assertIdentifier(input.id, "Grant id");
|
|
244
|
+
assertSha256(input.tokenHash, "Grant token hash");
|
|
245
|
+
if (!EXTENSION_ORIGIN_PATTERN.test(input.origin)) {
|
|
246
|
+
throw new TypeError(
|
|
247
|
+
"Grant origin must be an exact chrome-extension origin"
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
assertTimestamp(input.createdAtMs, "Grant creation time");
|
|
251
|
+
assertTimestamp(input.expiresAtMs, "Grant expiry");
|
|
252
|
+
if (input.expiresAtMs <= input.createdAtMs) {
|
|
253
|
+
throw new RangeError("Grant expiry must be after its creation time");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const insert = db.run(
|
|
257
|
+
`INSERT OR IGNORE INTO clipper_grants (
|
|
258
|
+
id, token_hash, origin, scope, created_at_ms, expires_at_ms
|
|
259
|
+
) VALUES (?, ?, ?, 'capture', ?, ?)`,
|
|
260
|
+
[
|
|
261
|
+
input.id,
|
|
262
|
+
input.tokenHash,
|
|
263
|
+
input.origin,
|
|
264
|
+
input.createdAtMs,
|
|
265
|
+
input.expiresAtMs,
|
|
266
|
+
]
|
|
267
|
+
);
|
|
268
|
+
const row = getGrantByIdentity(db, input.id, input.tokenHash);
|
|
269
|
+
if (!row) {
|
|
270
|
+
throw new Error("Failed to read persisted clipper grant");
|
|
271
|
+
}
|
|
272
|
+
const grant = mapGrant(row);
|
|
273
|
+
if (insert.changes > 0) return { status: "created", grant };
|
|
274
|
+
if (
|
|
275
|
+
row.id === input.id &&
|
|
276
|
+
row.token_hash === input.tokenHash &&
|
|
277
|
+
row.origin === input.origin &&
|
|
278
|
+
row.created_at_ms === input.createdAtMs &&
|
|
279
|
+
row.expires_at_ms === input.expiresAtMs
|
|
280
|
+
) {
|
|
281
|
+
return { status: "duplicate", grant };
|
|
282
|
+
}
|
|
283
|
+
return { status: "conflict" };
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
export const authorizeClipperGrant = (
|
|
287
|
+
db: Database,
|
|
288
|
+
tokenHash: string,
|
|
289
|
+
origin: string,
|
|
290
|
+
nowMs: number
|
|
291
|
+
): AuthorizeClipperGrantResult => {
|
|
292
|
+
assertSha256(tokenHash, "Grant token hash");
|
|
293
|
+
assertTimestamp(nowMs, "Authorization time");
|
|
294
|
+
if (!EXTENSION_ORIGIN_PATTERN.test(origin)) return { status: "unauthorized" };
|
|
295
|
+
const row = db
|
|
296
|
+
.query<DbClipperGrantRow, [string]>(
|
|
297
|
+
`SELECT id, token_hash, origin, scope, created_at_ms, expires_at_ms,
|
|
298
|
+
revoked_at_ms
|
|
299
|
+
FROM clipper_grants
|
|
300
|
+
WHERE token_hash = ?`
|
|
301
|
+
)
|
|
302
|
+
.get(tokenHash);
|
|
303
|
+
if (!row || row.origin !== origin) return { status: "unauthorized" };
|
|
304
|
+
const grant = mapGrant(row);
|
|
305
|
+
if (nowMs < grant.createdAtMs) return { status: "unauthorized" };
|
|
306
|
+
if (grant.revokedAtMs !== null) return { status: "revoked" };
|
|
307
|
+
if (nowMs >= grant.expiresAtMs) return { status: "expired" };
|
|
308
|
+
return { status: "authorized", grant };
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export const revokeClipperGrant = (
|
|
312
|
+
db: Database,
|
|
313
|
+
grantId: string,
|
|
314
|
+
nowMs: number
|
|
315
|
+
): RevokeClipperGrantResult => {
|
|
316
|
+
assertIdentifier(grantId, "Grant id");
|
|
317
|
+
assertTimestamp(nowMs, "Revocation time");
|
|
318
|
+
const row = db
|
|
319
|
+
.query<DbClipperGrantRow, [string]>(
|
|
320
|
+
`SELECT id, token_hash, origin, scope, created_at_ms, expires_at_ms,
|
|
321
|
+
revoked_at_ms
|
|
322
|
+
FROM clipper_grants
|
|
323
|
+
WHERE id = ?`
|
|
324
|
+
)
|
|
325
|
+
.get(grantId);
|
|
326
|
+
if (!row) return { status: "not_found" };
|
|
327
|
+
const grant = mapGrant(row);
|
|
328
|
+
if (grant.revokedAtMs !== null) {
|
|
329
|
+
return { status: "already_revoked", grant };
|
|
330
|
+
}
|
|
331
|
+
if (nowMs >= grant.expiresAtMs) return { status: "expired", grant };
|
|
332
|
+
const update = db.run(
|
|
333
|
+
`UPDATE clipper_grants
|
|
334
|
+
SET revoked_at_ms = ?
|
|
335
|
+
WHERE id = ? AND revoked_at_ms IS NULL`,
|
|
336
|
+
[Math.max(nowMs, grant.createdAtMs), grantId]
|
|
337
|
+
);
|
|
338
|
+
if (update.changes === 0) {
|
|
339
|
+
const current = getGrantById(db, grantId);
|
|
340
|
+
if (!current) return { status: "not_found" };
|
|
341
|
+
return { status: "already_revoked", grant: mapGrant(current) };
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
status: "revoked",
|
|
345
|
+
grant: { ...grant, revokedAtMs: Math.max(nowMs, grant.createdAtMs) },
|
|
346
|
+
};
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
export const claimClipperIdempotency = (
|
|
350
|
+
db: Database,
|
|
351
|
+
input: ClaimClipperIdempotencyInput
|
|
352
|
+
): ClaimClipperIdempotencyResult => {
|
|
353
|
+
assertIdentifier(input.grantId, "Grant id");
|
|
354
|
+
assertSha256(input.keyHash, "Idempotency key hash");
|
|
355
|
+
assertSha256(input.requestDigest, "Request digest");
|
|
356
|
+
assertPlan(input.plan);
|
|
357
|
+
assertTimestamp(input.nowMs, "Claim time");
|
|
358
|
+
const grant = db
|
|
359
|
+
.query<
|
|
360
|
+
{
|
|
361
|
+
created_at_ms: number;
|
|
362
|
+
expires_at_ms: number;
|
|
363
|
+
revoked_at_ms: number | null;
|
|
364
|
+
},
|
|
365
|
+
[string]
|
|
366
|
+
>(
|
|
367
|
+
`SELECT created_at_ms, expires_at_ms, revoked_at_ms
|
|
368
|
+
FROM clipper_grants
|
|
369
|
+
WHERE id = ?`
|
|
370
|
+
)
|
|
371
|
+
.get(input.grantId);
|
|
372
|
+
if (
|
|
373
|
+
!grant ||
|
|
374
|
+
grant.revoked_at_ms !== null ||
|
|
375
|
+
input.nowMs < grant.created_at_ms ||
|
|
376
|
+
input.nowMs >= grant.expires_at_ms
|
|
377
|
+
) {
|
|
378
|
+
return { status: "grant_inactive" };
|
|
379
|
+
}
|
|
380
|
+
const insert = db.run(
|
|
381
|
+
`INSERT OR IGNORE INTO clipper_capture_idempotency (
|
|
382
|
+
grant_id, key_hash, request_digest, collection, rel_path,
|
|
383
|
+
collision_policy_result, content_hash, clip_identity, state,
|
|
384
|
+
created_at_ms
|
|
385
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
|
|
386
|
+
[
|
|
387
|
+
input.grantId,
|
|
388
|
+
input.keyHash,
|
|
389
|
+
input.requestDigest,
|
|
390
|
+
input.plan.collection,
|
|
391
|
+
input.plan.relPath,
|
|
392
|
+
input.plan.collisionPolicyResult,
|
|
393
|
+
input.plan.contentHash,
|
|
394
|
+
input.plan.clipIdentity,
|
|
395
|
+
input.nowMs,
|
|
396
|
+
]
|
|
397
|
+
);
|
|
398
|
+
if (insert.changes > 0) return { status: "claimed" };
|
|
399
|
+
const keyRow = getIdempotencyRow(db, input.grantId, input.keyHash);
|
|
400
|
+
if (keyRow && keyRow.request_digest !== input.requestDigest) {
|
|
401
|
+
return { status: "conflict" };
|
|
402
|
+
}
|
|
403
|
+
const row =
|
|
404
|
+
keyRow ?? getIdempotencyRowByDigest(db, input.grantId, input.requestDigest);
|
|
405
|
+
if (!row) throw new Error("Failed to read clipper idempotency claim");
|
|
406
|
+
const storedPlan = planFromRow(row);
|
|
407
|
+
if (!plansMatch(storedPlan, input.plan)) {
|
|
408
|
+
return { status: "conflict" };
|
|
409
|
+
}
|
|
410
|
+
return idempotencyResultFromRow(row);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
export const inspectClipperIdempotency = (
|
|
414
|
+
db: Database,
|
|
415
|
+
input: InspectClipperIdempotencyInput
|
|
416
|
+
): InspectClipperIdempotencyResult => {
|
|
417
|
+
assertIdentifier(input.grantId, "Grant id");
|
|
418
|
+
assertSha256(input.keyHash, "Idempotency key hash");
|
|
419
|
+
assertSha256(input.requestDigest, "Request digest");
|
|
420
|
+
const keyRow = getIdempotencyRow(db, input.grantId, input.keyHash);
|
|
421
|
+
if (keyRow && keyRow.request_digest !== input.requestDigest) {
|
|
422
|
+
return { status: "conflict" };
|
|
423
|
+
}
|
|
424
|
+
const row =
|
|
425
|
+
keyRow ?? getIdempotencyRowByDigest(db, input.grantId, input.requestDigest);
|
|
426
|
+
return row ? idempotencyResultFromRow(row) : { status: "not_found" };
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
export const completeClipperIdempotency = (
|
|
430
|
+
db: Database,
|
|
431
|
+
input: CompleteClipperIdempotencyInput
|
|
432
|
+
): CompleteClipperIdempotencyResult => {
|
|
433
|
+
assertIdentifier(input.grantId, "Grant id");
|
|
434
|
+
assertSha256(input.keyHash, "Idempotency key hash");
|
|
435
|
+
assertSha256(input.requestDigest, "Request digest");
|
|
436
|
+
assertTimestamp(input.nowMs, "Completion time");
|
|
437
|
+
if (
|
|
438
|
+
!Number.isInteger(input.statusCode) ||
|
|
439
|
+
input.statusCode < 200 ||
|
|
440
|
+
input.statusCode > 599
|
|
441
|
+
) {
|
|
442
|
+
throw new RangeError("Response status code must be between 200 and 599");
|
|
443
|
+
}
|
|
444
|
+
if (
|
|
445
|
+
new TextEncoder().encode(input.responseJson).byteLength > MAX_RESPONSE_BYTES
|
|
446
|
+
) {
|
|
447
|
+
throw new RangeError(
|
|
448
|
+
`Clipper response exceeds ${MAX_RESPONSE_BYTES} UTF-8 bytes`
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
JSON.parse(input.responseJson);
|
|
453
|
+
} catch {
|
|
454
|
+
throw new TypeError("Clipper response must be valid JSON");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const keyRow = getIdempotencyRow(db, input.grantId, input.keyHash);
|
|
458
|
+
if (keyRow && keyRow.request_digest !== input.requestDigest) {
|
|
459
|
+
return { status: "conflict" };
|
|
460
|
+
}
|
|
461
|
+
const existing =
|
|
462
|
+
keyRow ?? getIdempotencyRowByDigest(db, input.grantId, input.requestDigest);
|
|
463
|
+
if (!existing) return { status: "not_found" };
|
|
464
|
+
const existingReplay = replayFromRow(existing);
|
|
465
|
+
if (existingReplay) {
|
|
466
|
+
return existingReplay.statusCode === input.statusCode &&
|
|
467
|
+
existingReplay.responseJson === input.responseJson
|
|
468
|
+
? { status: "replay", replay: existingReplay }
|
|
469
|
+
: { status: "conflict" };
|
|
470
|
+
}
|
|
471
|
+
const update = db.run(
|
|
472
|
+
`UPDATE clipper_capture_idempotency
|
|
473
|
+
SET state = 'completed',
|
|
474
|
+
status_code = ?,
|
|
475
|
+
response_json = ?,
|
|
476
|
+
completed_at_ms = ?
|
|
477
|
+
WHERE grant_id = ? AND key_hash = ? AND request_digest = ?
|
|
478
|
+
AND state = 'pending'`,
|
|
479
|
+
[
|
|
480
|
+
input.statusCode,
|
|
481
|
+
input.responseJson,
|
|
482
|
+
input.nowMs,
|
|
483
|
+
input.grantId,
|
|
484
|
+
existing.key_hash,
|
|
485
|
+
input.requestDigest,
|
|
486
|
+
]
|
|
487
|
+
);
|
|
488
|
+
const completed = getIdempotencyRow(db, input.grantId, existing.key_hash);
|
|
489
|
+
const replay = completed ? replayFromRow(completed) : null;
|
|
490
|
+
if (!replay) throw new Error("Failed to complete clipper idempotency claim");
|
|
491
|
+
if (update.changes > 0) return { status: "completed", replay };
|
|
492
|
+
return replay.statusCode === input.statusCode &&
|
|
493
|
+
replay.responseJson === input.responseJson
|
|
494
|
+
? { status: "replay", replay }
|
|
495
|
+
: { status: "conflict" };
|
|
496
|
+
};
|