@synoi/gap 0.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.
Files changed (58) hide show
  1. package/LICENSE +195 -0
  2. package/README.md +223 -0
  3. package/dist/canonicalize.d.ts +19 -0
  4. package/dist/canonicalize.d.ts.map +1 -0
  5. package/dist/canonicalize.js +36 -0
  6. package/dist/canonicalize.js.map +1 -0
  7. package/dist/capabilities.d.ts +605 -0
  8. package/dist/capabilities.d.ts.map +1 -0
  9. package/dist/capabilities.js +53 -0
  10. package/dist/capabilities.js.map +1 -0
  11. package/dist/cdro.d.ts +63 -0
  12. package/dist/cdro.d.ts.map +1 -0
  13. package/dist/cdro.js +16 -0
  14. package/dist/cdro.js.map +1 -0
  15. package/dist/channels.d.ts +107 -0
  16. package/dist/channels.d.ts.map +1 -0
  17. package/dist/channels.js +29 -0
  18. package/dist/channels.js.map +1 -0
  19. package/dist/constants.d.ts +32 -0
  20. package/dist/constants.d.ts.map +1 -0
  21. package/dist/constants.js +36 -0
  22. package/dist/constants.js.map +1 -0
  23. package/dist/index.d.ts +28 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +35 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/oid.d.ts +28 -0
  28. package/dist/oid.d.ts.map +1 -0
  29. package/dist/oid.js +68 -0
  30. package/dist/oid.js.map +1 -0
  31. package/dist/receipts.d.ts +128 -0
  32. package/dist/receipts.d.ts.map +1 -0
  33. package/dist/receipts.js +14 -0
  34. package/dist/receipts.js.map +1 -0
  35. package/dist/revocations.d.ts +65 -0
  36. package/dist/revocations.d.ts.map +1 -0
  37. package/dist/revocations.js +22 -0
  38. package/dist/revocations.js.map +1 -0
  39. package/dist/validate.d.ts +59 -0
  40. package/dist/validate.d.ts.map +1 -0
  41. package/dist/validate.js +835 -0
  42. package/dist/validate.js.map +1 -0
  43. package/dist/workflows.d.ts +186 -0
  44. package/dist/workflows.d.ts.map +1 -0
  45. package/dist/workflows.js +14 -0
  46. package/dist/workflows.js.map +1 -0
  47. package/package.json +55 -0
  48. package/src/canonicalize.ts +38 -0
  49. package/src/capabilities.ts +711 -0
  50. package/src/cdro.ts +92 -0
  51. package/src/channels.ts +183 -0
  52. package/src/constants.ts +46 -0
  53. package/src/index.ts +180 -0
  54. package/src/oid.ts +71 -0
  55. package/src/receipts.ts +169 -0
  56. package/src/revocations.ts +90 -0
  57. package/src/validate.ts +1008 -0
  58. package/src/workflows.ts +241 -0
package/src/cdro.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * cdro.ts -- GAP CDRO envelope.
3
+ *
4
+ * CDRO = Content-addressed, Deterministic, Replayable Object. Every GAP
5
+ * top-level record sits inside a `GapCdroEnvelope<TBody>`. The shape mirrors
6
+ * the GAP gateway reference implementation wire types.
7
+ *
8
+ * NOTE: this envelope shape is locally redeclared in @synoi/gap rather
9
+ * than imported from @synoi/sraid (which is being built in parallel). The two
10
+ * packages will be wired together in a follow-up. The wire format is
11
+ * identical, so cross-package compatibility is by-shape.
12
+ */
13
+
14
+ export type GapObjectType =
15
+ // -- v1 REFERENCE IMPLEMENTATION TYPES (conformance-backed) --
16
+ // These 10 types are emitted by the v1 reference implementation and have
17
+ // conformance vectors in synoi-conformance. They are the only types that
18
+ // are considered SHIPPED for interoperability purposes.
19
+ | 'gap:capability_declaration'
20
+ | 'gap:capability_grant'
21
+ | 'gap:capability_invocation'
22
+ | 'gap:workflow_definition'
23
+ | 'gap:workflow_instance'
24
+ | 'gap:stage_transition'
25
+ | 'gap:channel_event'
26
+ | 'gap:decision_receipt'
27
+ | 'gap:revocation_event'
28
+ | 'gap:federation_handshake' // reserved for GAP 1.1 - not part of the active 1.0 conformance surface
29
+ // -- DRAFT TYPES (defined in spec; not yet emitted by the v1 reference
30
+ // implementation; no conformance vector yet) --
31
+ // Do NOT rely on these for interoperability. They will graduate to the
32
+ // conformance-backed set when the reference implementation ships them and
33
+ // vectors are added to synoi-conformance.
34
+ | 'gap:break_glass_token' // DRAFT: break-glass elevated access token
35
+ | 'gap:local_override_credential' // DRAFT: local policy override credential
36
+ | 'gap:lca_root' // DRAFT: lowest-common-ancestor trust root
37
+ | 'gap:erasure_event' // DRAFT: GDPR-style data erasure audit record
38
+ | 'gap:orchestration_chain' // DRAFT: agent delegation chain (Item 1)
39
+ | 'gap:consent_record' // DRAFT: consent version chain (Item 4)
40
+ | 'gap:pip_response' // DRAFT: signed PIP response (Item 7)
41
+
42
+ /** Current GAP wire version. CDROs that don't match this version are
43
+ * rejected by validators. */
44
+ export const GAP_VERSION = '1.0' as const
45
+ export type GapVersion = typeof GAP_VERSION
46
+
47
+ /**
48
+ * Fields excluded from the OID hash input (they are NOT part of the canonical
49
+ * body): `oid`, `gap_version`, `signature`, `signature_key_id`, `supersedes`.
50
+ *
51
+ * Any future addition to this exclusion set constitutes a protocol version
52
+ * bump. Implementors must strip all five fields before passing the object to
53
+ * `computeGapOid` / `canonicalize`.
54
+ */
55
+ export interface GapCdroEnvelope<TBody> {
56
+ /** Content-addressed identifier: `sha256:<hex>` over canonical body. */
57
+ oid: string
58
+ /** Object type discriminator, e.g. `gap:capability_grant`. */
59
+ type: GapObjectType
60
+ /** Wire version of the GAP protocol. */
61
+ gap_version: GapVersion
62
+ /** Tenant scope. CDROs never cross tenant boundaries implicitly. */
63
+ tenant_id: string
64
+ /** Server-clock millisecond timestamp at envelope construction. */
65
+ created_at_ms: number
66
+ /** Actor OID that created this CDRO. */
67
+ created_by: string
68
+ /** Type-specific payload. */
69
+ body: TBody
70
+ /** Optional Ed25519 signature, base64-encoded. */
71
+ signature?: string
72
+ /** Identifier of the public key that produced `signature`. */
73
+ signature_key_id?: string
74
+ /** OID of a prior CDRO that this one replaces. */
75
+ supersedes?: string
76
+ }
77
+
78
+ /**
79
+ * Payload shape passed to `computeGapOid` -- the envelope minus oid +
80
+ * gap_version + signature fields. Useful when builders are constructing a
81
+ * CDRO step-by-step.
82
+ *
83
+ * This is the canonical input shape for `computeGapOid` -- it already excludes
84
+ * `oid`, `gap_version`, `signature`, `signature_key_id`, and `supersedes`.
85
+ */
86
+ export interface GapOidPayload<TBody> {
87
+ type: GapObjectType
88
+ tenant_id: string
89
+ created_at_ms: number
90
+ created_by: string
91
+ body: TBody
92
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * channels.ts -- channel taxonomy + adapter interface.
3
+ *
4
+ * Channels bridge GAP's abstract `actions`/`listen` model to concrete
5
+ * delivery surfaces (SMS, mobile push, home assistant, etc.).
6
+ *
7
+ * This file declares only the TYPES. Adapter implementations live in
8
+ * downstream packages (the gateway, vendor SDKs). The interface here is the
9
+ * contract those implementations satisfy.
10
+ */
11
+
12
+ import type { GapCdroEnvelope } from './cdro.js'
13
+
14
+ /** Built-in channel kinds. The `(string & {})` branch keeps the union open
15
+ * for vendor-specific extensions (e.g. 'com.example.pager') while
16
+ * preserving autocomplete on the canonical entries.
17
+ *
18
+ * Connectivity categories:
19
+ * Internet: sms, voice, email, slack, mobile_push
20
+ * LAN/Internet: sse, webhook
21
+ * Local: in_app, game_engine, home_assistant, desktop_overlay
22
+ * Air-gapped: local_terminal, hmi_panel, opc_ua_ack, local_signed_token
23
+ */
24
+ export type ChannelKind =
25
+ // Internet channels
26
+ | 'sms'
27
+ | 'voice'
28
+ | 'email'
29
+ | 'slack'
30
+ | 'mobile_push'
31
+ // LAN/Internet channels
32
+ | 'sse'
33
+ | 'webhook'
34
+ // Local channels
35
+ | 'in_app'
36
+ | 'game_engine'
37
+ | 'home_assistant'
38
+ | 'desktop_overlay'
39
+ // Air-gapped / local enforcement point channels
40
+ | 'local_terminal'
41
+ | 'hmi_panel'
42
+ | 'opc_ua_ack'
43
+ | 'local_signed_token'
44
+ // Extensible for custom adapters; use reverse-domain prefix e.g. 'com.example.pager'
45
+ | (string & {})
46
+
47
+ /** Subset of ChannelKind containing only the canonical (literal) values.
48
+ * Useful for exhaustive switches in code that only handles built-ins. */
49
+ export type CanonicalChannelKind =
50
+ | 'sms'
51
+ | 'voice'
52
+ | 'email'
53
+ | 'slack'
54
+ | 'mobile_push'
55
+ | 'sse'
56
+ | 'webhook'
57
+ | 'in_app'
58
+ | 'game_engine'
59
+ | 'home_assistant'
60
+ | 'desktop_overlay'
61
+ | 'local_terminal'
62
+ | 'hmi_panel'
63
+ | 'opc_ua_ack'
64
+ | 'local_signed_token'
65
+
66
+ /** Ordered list of canonical channels, useful for menu UIs + tests. */
67
+ export const CANONICAL_CHANNEL_KINDS: readonly CanonicalChannelKind[] = [
68
+ 'sms',
69
+ 'voice',
70
+ 'email',
71
+ 'slack',
72
+ 'mobile_push',
73
+ 'sse',
74
+ 'webhook',
75
+ 'in_app',
76
+ 'game_engine',
77
+ 'home_assistant',
78
+ 'desktop_overlay',
79
+ 'local_terminal',
80
+ 'hmi_panel',
81
+ 'opc_ua_ack',
82
+ 'local_signed_token',
83
+ ] as const
84
+
85
+ // -- Stage primitives that reference channels --------------------------------
86
+
87
+ export interface StageAction {
88
+ channel: ChannelKind
89
+ method: string
90
+ params: Record<string, unknown>
91
+ }
92
+
93
+ export interface StageTransitionTarget {
94
+ next_stage_id?: string
95
+ bind?: Record<string, string>
96
+ }
97
+
98
+ export interface StageListen {
99
+ channel: ChannelKind
100
+ intent?: string
101
+ pattern?: string
102
+ event_kind?: string
103
+ next: StageTransitionTarget
104
+ /**
105
+ * When set, the gateway MUST verify that the channel event's `from` field
106
+ * matches this value before accepting it as a valid stage signal. For SMS
107
+ * channels this is the operator's registered phone number (E.164 format).
108
+ * For webhook channels this is the expected sender identity string.
109
+ *
110
+ * Required for stages that govern physical_safety=true or safety_class C
111
+ * capabilities. Absent means no sender-identity check is performed.
112
+ */
113
+ required_from_binding?: string
114
+ }
115
+
116
+ // -- ChannelEvent CDRO -------------------------------------------------------
117
+
118
+ export interface ChannelEventBody {
119
+ channel: ChannelKind
120
+ event_kind: string
121
+ payload: Record<string, unknown>
122
+ observed_at_ms: number
123
+ /** Workflow context if this event originated from / is routed to a workflow. */
124
+ workflow_instance_oid?: string
125
+ stage_id?: string
126
+ }
127
+
128
+ export type ChannelEvent = GapCdroEnvelope<ChannelEventBody>
129
+
130
+ // -- ChannelAdapter interface ------------------------------------------------
131
+
132
+ export interface AdapterContext {
133
+ tenant_id: string
134
+ workflow_instance_oid: string
135
+ stage_id: string
136
+ scope_variables: Record<string, unknown>
137
+ }
138
+
139
+ export interface ActionResult {
140
+ ok: boolean
141
+ detail?: string
142
+ /** OID of a channel event spawned by the action, if any. */
143
+ spawned_event_oid?: string
144
+ }
145
+
146
+ export interface ListenHandle {
147
+ cancel(): void
148
+ }
149
+
150
+ /**
151
+ * Interface that every channel adapter must implement. Adapter
152
+ * implementations are out of scope for this types package -- they live in
153
+ * synoi-gateway and downstream packages.
154
+ */
155
+ export interface ChannelAdapter {
156
+ /** Channel kind this adapter handles. */
157
+ kind: ChannelKind
158
+
159
+ /** Adapter capabilities -- which GAP listen/action shapes it supports. */
160
+ supports: {
161
+ actions: string[]
162
+ listens: Array<'intent' | 'pattern' | 'event_kind'>
163
+ }
164
+
165
+ /** Execute an action. Returns when complete or errors. */
166
+ performAction(spec: StageAction, context: AdapterContext): Promise<ActionResult>
167
+
168
+ /** Arm a listener. Returns a handle that can be cancelled. */
169
+ armListen(
170
+ spec: StageListen,
171
+ context: AdapterContext,
172
+ onMatch: (event: ChannelEvent) => void,
173
+ ): ListenHandle
174
+
175
+ /** Health check. */
176
+ health(): Promise<{ ok: boolean; detail?: string }>
177
+ }
178
+
179
+ export interface ChannelRegistry {
180
+ register(adapter: ChannelAdapter): void
181
+ get(kind: ChannelKind): ChannelAdapter | null
182
+ list(): ChannelAdapter[]
183
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * constants.ts -- well-known capability OIDs + channel kinds.
3
+ *
4
+ * Reserved OID strings for the GAP platform. These identifiers are stable
5
+ * across all conforming gateway implementations. Third-party implementations
6
+ * MUST NOT redefine them.
7
+ *
8
+ * - DISCOVERY_QUERY_CAPABILITY reserved for /by-grant discovery queries
9
+ * - SKILL_CREATE_CAPABILITY reserved for skill manifest upload
10
+ * - VOICE_JOIN_CAPABILITY reserved for voice bridge authorization
11
+ */
12
+
13
+ import type { CanonicalChannelKind } from './channels.js'
14
+
15
+ // -- Well-known capability names (dotted taxonomy) ---------------------------
16
+
17
+ /** Capability that authorizes /by-grant discovery queries. */
18
+ export const DISCOVERY_QUERY_CAPABILITY = 'gap.discovery.query' as const
19
+
20
+ /** Capability that authorizes skill creation (skill manifest upload). */
21
+ export const SKILL_CREATE_CAPABILITY = 'skill.create' as const
22
+
23
+ /** Capability that authorizes joining a voice bridge call. */
24
+ export const VOICE_JOIN_CAPABILITY = 'gap.voice.join' as const
25
+
26
+ /** All well-known capability names, useful for tests + audit dashboards. */
27
+ export const WELL_KNOWN_CAPABILITIES = [
28
+ DISCOVERY_QUERY_CAPABILITY,
29
+ SKILL_CREATE_CAPABILITY,
30
+ VOICE_JOIN_CAPABILITY,
31
+ ] as const
32
+
33
+ export type WellKnownCapability = typeof WELL_KNOWN_CAPABILITIES[number]
34
+
35
+ // -- Channel kind constants (mirror canonical list) --------------------------
36
+
37
+ export const CHANNEL_VOICE: CanonicalChannelKind = 'voice'
38
+ export const CHANNEL_SMS: CanonicalChannelKind = 'sms'
39
+ export const CHANNEL_SLACK: CanonicalChannelKind = 'slack'
40
+ export const CHANNEL_MOBILE_PUSH: CanonicalChannelKind = 'mobile_push'
41
+ export const CHANNEL_HOME_ASSISTANT: CanonicalChannelKind = 'home_assistant'
42
+ export const CHANNEL_DESKTOP_OVERLAY: CanonicalChannelKind = 'desktop_overlay'
43
+ export const CHANNEL_EMAIL: CanonicalChannelKind = 'email'
44
+ export const CHANNEL_IN_APP: CanonicalChannelKind = 'in_app'
45
+ export const CHANNEL_GAME_ENGINE: CanonicalChannelKind = 'game_engine'
46
+ export const CHANNEL_WEBHOOK: CanonicalChannelKind = 'webhook'
package/src/index.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * @synoi/gap -- public surface.
3
+ *
4
+ * Apache-2.0 TypeScript types + runtime validators for SynOI's
5
+ * GAP (Governed Action Protocol).
6
+ *
7
+ * The protocol itself is open under CC0; this package ships the wire-format
8
+ * types so any GAP implementation (third-party gateway, audit tool, vendor
9
+ * SDK) can speak the same wire format from a single source of truth.
10
+ */
11
+
12
+ // -- CDRO envelope -----------------------------------------------------------
13
+ export {
14
+ GAP_VERSION,
15
+ } from './cdro.js'
16
+ export type {
17
+ GapCdroEnvelope,
18
+ GapObjectType,
19
+ GapOidPayload,
20
+ GapVersion,
21
+ } from './cdro.js'
22
+
23
+ // -- Capabilities (declarations, grants, invocations) ------------------------
24
+ export {
25
+ capabilityMatches,
26
+ } from './capabilities.js'
27
+ export type {
28
+ GapActorType,
29
+ Capability,
30
+ CapabilityDeclaration,
31
+ CapabilityDeclarationBody,
32
+ CapabilityGrant,
33
+ CapabilityGrantBody,
34
+ CapabilityInvocation,
35
+ CapabilityInvocationBody,
36
+ CapabilityPredicate,
37
+ GrantedCapabilityScope,
38
+ // Item 1: Agent Delegation Chain
39
+ DelegationStep,
40
+ OrchestrationChainBody,
41
+ // Item 2: MCP Tool-Call Governance
42
+ McpToolCallContext,
43
+ // Item 3: Token Budget Governance
44
+ TokenBudgetArgs,
45
+ // Item 4: Consent Version Chain
46
+ ConsentRecordBody,
47
+ // Item 5: Identity Binding
48
+ CredentialKind,
49
+ IdentityBinding,
50
+ // Item 6: Compartment-Based Access Scoping (fields added to existing types)
51
+ // Item 7: Signed PIP Response
52
+ ExternalPipArgs,
53
+ PipResponseBody,
54
+ } from './capabilities.js'
55
+
56
+ // -- Channels ----------------------------------------------------------------
57
+ export {
58
+ CANONICAL_CHANNEL_KINDS,
59
+ } from './channels.js'
60
+ export type {
61
+ ActionResult,
62
+ AdapterContext,
63
+ CanonicalChannelKind,
64
+ ChannelAdapter,
65
+ ChannelEvent,
66
+ ChannelEventBody,
67
+ ChannelKind,
68
+ ChannelRegistry,
69
+ ListenHandle,
70
+ StageAction,
71
+ StageListen,
72
+ StageTransitionTarget,
73
+ } from './channels.js'
74
+
75
+ // -- Workflows ---------------------------------------------------------------
76
+ export type {
77
+ OptionalEffect,
78
+ StageSafety,
79
+ StageTransition,
80
+ StageTransitionBody,
81
+ StageTransitionReason,
82
+ WorkflowDefinition,
83
+ WorkflowDefinitionBody,
84
+ WorkflowInstance,
85
+ WorkflowInstanceBody,
86
+ WorkflowStage,
87
+ WorkflowStageDefinition,
88
+ WorkflowTrigger,
89
+ WorkflowTriggerKind,
90
+ } from './workflows.js'
91
+
92
+ // -- Receipts + failures -----------------------------------------------------
93
+ export {
94
+ isGapFailure,
95
+ } from './receipts.js'
96
+ export type {
97
+ GapDecisionReceipt,
98
+ GapDecisionReceiptBody,
99
+ GapFailure,
100
+ GapFailureReason,
101
+ DecisionStatus,
102
+ DecisionSubjectKind,
103
+ // Item 3: Token Budget Governance
104
+ TokenConsumption,
105
+ } from './receipts.js'
106
+
107
+ // -- Revocations -------------------------------------------------------------
108
+ export {
109
+ revokeGapObject,
110
+ } from './revocations.js'
111
+ export type {
112
+ RevocationEvent,
113
+ RevocationEventBody,
114
+ RevocationTargetKind,
115
+ } from './revocations.js'
116
+
117
+ // -- Constants ---------------------------------------------------------------
118
+ export {
119
+ CHANNEL_DESKTOP_OVERLAY,
120
+ CHANNEL_EMAIL,
121
+ CHANNEL_GAME_ENGINE,
122
+ CHANNEL_HOME_ASSISTANT,
123
+ CHANNEL_IN_APP,
124
+ CHANNEL_MOBILE_PUSH,
125
+ CHANNEL_SLACK,
126
+ CHANNEL_SMS,
127
+ CHANNEL_VOICE,
128
+ CHANNEL_WEBHOOK,
129
+ DISCOVERY_QUERY_CAPABILITY,
130
+ SKILL_CREATE_CAPABILITY,
131
+ VOICE_JOIN_CAPABILITY,
132
+ WELL_KNOWN_CAPABILITIES,
133
+ } from './constants.js'
134
+ export type {
135
+ WellKnownCapability,
136
+ } from './constants.js'
137
+
138
+ // -- OID + canonicalize ------------------------------------------------------
139
+ export {
140
+ computeGapOid,
141
+ } from './oid.js'
142
+ export {
143
+ canonicalize,
144
+ } from './canonicalize.js'
145
+
146
+ // -- Validators --------------------------------------------------------------
147
+ export type {
148
+ ValidationResult,
149
+ } from './validate.js'
150
+ export {
151
+ validateGapDecisionReceipt,
152
+ validateGapDecisionReceiptBody,
153
+ validateCapabilityDeclaration,
154
+ validateCapabilityDeclarationBody,
155
+ validateCapabilityGrant,
156
+ validateCapabilityGrantBody,
157
+ validateCapabilityInvocation,
158
+ validateCapabilityInvocationBody,
159
+ validateChannelEvent,
160
+ validateChannelEventBody,
161
+ validateRevocationEvent,
162
+ validateRevocationEventBody,
163
+ validateStageTransition,
164
+ validateStageTransitionBody,
165
+ validateWorkflowDefinition,
166
+ validateWorkflowDefinitionBody,
167
+ validateWorkflowInstance,
168
+ validateWorkflowInstanceBody,
169
+ // Item 1: Agent Delegation Chain
170
+ validateOrchestrationChainBody,
171
+ validateOrchestrationChain,
172
+ // Item 3: Token Budget Governance
173
+ validateTokenConsumption,
174
+ // Item 4: Consent Version Chain
175
+ validateConsentRecordBody,
176
+ validateConsentRecord,
177
+ // Item 7: Signed PIP Response
178
+ validatePipResponseBody,
179
+ validatePipResponse,
180
+ } from './validate.js'
package/src/oid.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * oid.ts -- OID computation for GAP CDROs.
3
+ *
4
+ * Implements RFC 8785 JCS canonical JSON. See IMPLEMENTING.md §2.2 for the
5
+ * normative rules.
6
+ *
7
+ * sha256(canonicalize(envelope_minus_excluded_fields))
8
+ *
9
+ * Excluded fields (stripped before hashing): oid, gap_version, signature,
10
+ * signature_key_id, supersedes. Signatures are added after OID computation.
11
+ *
12
+ * The shape passed in is the OID payload: `{ type, tenant_id, created_at_ms,
13
+ * created_by, body }`. The full envelope (with oid + gap_version) is built
14
+ * around it.
15
+ */
16
+
17
+ import { sha256 } from '@noble/hashes/sha256'
18
+ import { canonicalize } from './canonicalize.js'
19
+
20
+ /** Convert a Uint8Array to a lowercase hex string. */
21
+ function bytesToHex(bytes: Uint8Array): string {
22
+ const hex: string[] = []
23
+ for (let i = 0; i < bytes.length; i++) {
24
+ const b = bytes[i] as number
25
+ hex.push((b >>> 4).toString(16))
26
+ hex.push((b & 0x0f).toString(16))
27
+ }
28
+ return hex.join('')
29
+ }
30
+
31
+ /**
32
+ * Fields excluded from the OID hash. These are present in the full envelope
33
+ * but MUST NOT contribute to the content hash. Strip them before hashing so
34
+ * that TypeScript and Python produce byte-identical OIDs regardless of whether
35
+ * the caller passes a pre-stripped payload or a full envelope.
36
+ */
37
+ const EXCLUDED_FIELDS = new Set([
38
+ 'oid',
39
+ 'gap_version',
40
+ 'signature',
41
+ 'signature_key_id',
42
+ 'supersedes',
43
+ ])
44
+
45
+ /**
46
+ * Compute the OID of a GAP CDRO payload.
47
+ *
48
+ * Accepts either a pre-stripped payload or a full envelope (with oid,
49
+ * gap_version, signature, signature_key_id, supersedes present). The 5
50
+ * excluded fields are stripped before canonicalization so both forms produce
51
+ * the same OID.
52
+ *
53
+ * @param body - the OID payload or full envelope (see CDRO §2.1 in GAP_SPEC).
54
+ * @returns the canonical OID string `"sha256:<hex>"`.
55
+ */
56
+ export function computeGapOid(body: unknown): string {
57
+ let stripped: unknown = body
58
+ if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
59
+ const obj = body as Record<string, unknown>
60
+ const result: Record<string, unknown> = {}
61
+ for (const key of Object.keys(obj)) {
62
+ if (!EXCLUDED_FIELDS.has(key)) {
63
+ result[key] = obj[key]
64
+ }
65
+ }
66
+ stripped = result
67
+ }
68
+ const canonical = canonicalize(stripped)
69
+ const digest = sha256(new TextEncoder().encode(canonical))
70
+ return 'sha256:' + bytesToHex(digest)
71
+ }