@openwop/openwop-conformance 1.21.0 → 1.24.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/CHANGELOG.md +43 -2
- package/README.md +61 -63
- package/api/asyncapi.yaml +108 -38
- package/api/openapi.yaml +34 -6
- package/coverage.md +389 -202
- package/fixtures/connection-packs/connection-pack-github.json +31 -0
- package/fixtures.md +120 -101
- package/package.json +1 -1
- package/schemas/README.md +4 -0
- package/schemas/capabilities.schema.json +127 -0
- package/schemas/connection-pack-manifest.schema.json +161 -0
- package/schemas/export-bundle.schema.json +66 -0
- package/schemas/goal.schema.json +104 -0
- package/schemas/proposal.schema.json +84 -0
- package/schemas/run-event-payloads.schema.json +86 -7
- package/schemas/run-event.schema.json +17 -3
- package/schemas/run-options.schema.json +1 -2
- package/schemas/run-snapshot.schema.json +2 -1
- package/schemas/suspend-request.schema.json +5 -0
- package/src/scenarios/connection-pack-manifest-valid.test.ts +122 -0
- package/src/scenarios/connection-pack-no-credential-material.test.ts +125 -0
- package/src/scenarios/connection-pack-reach-exclusive.test.ts +85 -0
- package/src/scenarios/connection-pack-write-reconsent.test.ts +91 -0
- package/src/scenarios/connection-provider-resolution.test.ts +153 -0
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +3 -3
- package/src/scenarios/export-bundle-portability.test.ts +120 -0
- package/src/scenarios/fixtures-valid.test.ts +34 -0
- package/src/scenarios/goal-standing-continuation.test.ts +139 -0
- package/src/scenarios/grpc-transport.test.ts +108 -0
- package/src/scenarios/i18n-negotiation.test.ts +181 -0
- package/src/scenarios/interrupt-token-matrix.test.ts +2 -2
- package/src/scenarios/media-url-inline-cap.test.ts +5 -3
- package/src/scenarios/proposal-reviewable-learning.test.ts +129 -0
- package/src/scenarios/spec-corpus-validity.test.ts +107 -0
- package/src/scenarios/stream-text-fixture.test.ts +212 -0
- package/src/scenarios/version-fold.test.ts +193 -0
- package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -2
- package/src/scenarios/webhook-tenant-isolation.test.ts +184 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://openwop.dev/spec/v1/run-event-payloads.schema.json",
|
|
4
4
|
"title": "RunEventPayloads",
|
|
5
|
-
"description": "Per-RunEventType payload schemas. The base RunEventDoc shape (run-event.schema.json) leaves `payload` permissive for forward-compat. This schema defines the canonical payload contract for each known RunEventType. Consumers MAY pin strict payload validation via `$defs.<typeId>` and `ajv.validate(schema.$defs[event.type], event.payload)`. Unknown event types MUST be tolerated (no $defs match → fold best-effort).\n\
|
|
5
|
+
"description": "Per-RunEventType payload schemas. The base RunEventDoc shape (run-event.schema.json) leaves `payload` permissive for forward-compat. This schema defines the canonical payload contract for each known RunEventType. Consumers MAY pin strict payload validation via `$defs.<typeId>` and `ajv.validate(schema.$defs[event.type], event.payload)`. Unknown event types MUST be tolerated (no $defs match → fold best-effort).\n\n100 variants from `run-event.schema.json#$defs.RunEventType` are covered, grouped into ~20 shape families with shared $defs. Naming convention: camelCase keys mirror dotted RunEventType names (e.g., `run.started` → `runStarted`).",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"$defs": {
|
|
8
8
|
"_typeIndex": {
|
|
@@ -104,7 +104,12 @@
|
|
|
104
104
|
"budget.reserved": { "$ref": "#/$defs/budgetReserved" },
|
|
105
105
|
"budget.consumed": { "$ref": "#/$defs/budgetConsumed" },
|
|
106
106
|
"budget.threshold.crossed": { "$ref": "#/$defs/budgetThresholdCrossed" },
|
|
107
|
-
"budget.exhausted": { "$ref": "#/$defs/budgetExhausted" }
|
|
107
|
+
"budget.exhausted": { "$ref": "#/$defs/budgetExhausted" },
|
|
108
|
+
"proposal.created": { "$ref": "#/$defs/proposalCreated" },
|
|
109
|
+
"proposal.activated": { "$ref": "#/$defs/proposalActivated" },
|
|
110
|
+
"goal.evaluated": { "$ref": "#/$defs/goalEvaluated" },
|
|
111
|
+
"goal.closed": { "$ref": "#/$defs/goalClosed" },
|
|
112
|
+
"import.applied": { "$ref": "#/$defs/importApplied" }
|
|
108
113
|
}
|
|
109
114
|
},
|
|
110
115
|
|
|
@@ -122,6 +127,79 @@
|
|
|
122
127
|
}
|
|
123
128
|
},
|
|
124
129
|
|
|
130
|
+
"proposalCreated": {
|
|
131
|
+
"description": "RFC 0096 §D. Emitted when the host synthesizes a reviewable-learning draft. Content-free: ids / kind / content-free references only — NEVER the artifact body or the rationale text (those live behind the authed read). Redaction-safe (SECURITY invariant `proposal-inert-until-applied` covers the behavior; SR-1 covers the payload). MUST NOT be emitted unless `capabilities.agents.proposals` is advertised.",
|
|
132
|
+
"type": "object",
|
|
133
|
+
"additionalProperties": false,
|
|
134
|
+
"required": ["proposalId", "kind"],
|
|
135
|
+
"properties": {
|
|
136
|
+
"proposalId": { "type": "string", "minLength": 1, "description": "The created proposal's stable id." },
|
|
137
|
+
"kind": { "type": "string", "enum": ["agent-pack", "workflow-chain-pack", "prompt-template", "automation"], "description": "The proposed artifact kind." },
|
|
138
|
+
"sourceRunIds": { "type": "array", "items": { "type": "string" }, "description": "Runs whose traces produced the draft (RFC 0040 causation-compatible). Ids only — no trace content." },
|
|
139
|
+
"duplicateOf": { "type": ["string", "null"], "description": "Existing artifact ref the proposal restates/overlaps, when duplication detection is on; else null." }
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
"proposalActivated": {
|
|
144
|
+
"description": "RFC 0096 §D. Emitted on a successful `apply`. Content-free: ids / content-free references only — NEVER the installed artifact body. MUST NOT be emitted unless `capabilities.agents.proposals` is advertised.",
|
|
145
|
+
"type": "object",
|
|
146
|
+
"additionalProperties": false,
|
|
147
|
+
"required": ["proposalId", "kind", "installedArtifactRef"],
|
|
148
|
+
"properties": {
|
|
149
|
+
"proposalId": { "type": "string", "minLength": 1 },
|
|
150
|
+
"kind": { "type": "string", "enum": ["agent-pack", "workflow-chain-pack", "prompt-template", "automation"] },
|
|
151
|
+
"approvalId": { "type": ["string", "null"], "description": "RFC 0051 approval id when activation routed through an approval gate; null for direct-rbac." },
|
|
152
|
+
"installedArtifactRef": { "type": "string", "minLength": 1, "description": "Ref of the artifact installed on apply (RFC 0043)." }
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
"goalEvaluated": {
|
|
157
|
+
"description": "RFC 0097 §D. Emitted after each judge check on a standing goal. Content-free: NO objective text. The verdict (`satisfied`/`confidence`) is non-deterministic judge output — it is RECORDED here and MUST NOT be recomputed on replay/fork (`replay.md`). MUST NOT be emitted unless `capabilities.agents.goals` is advertised.",
|
|
158
|
+
"type": "object",
|
|
159
|
+
"additionalProperties": false,
|
|
160
|
+
"required": ["goalId", "satisfied", "runId", "iterations"],
|
|
161
|
+
"properties": {
|
|
162
|
+
"goalId": { "type": "string", "minLength": 1 },
|
|
163
|
+
"satisfied": { "type": "boolean", "description": "The judge's verdict for this check." },
|
|
164
|
+
"confidence": { "type": "number", "minimum": 0, "maximum": 1, "description": "Judge confidence in [0,1]." },
|
|
165
|
+
"runId": { "type": "string", "minLength": 1, "description": "The run this verdict evaluated (RFC 0040 causation-compatible)." },
|
|
166
|
+
"iterations": { "type": "integer", "minimum": 0, "description": "Contributing iterations so far." }
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
"goalClosed": {
|
|
171
|
+
"description": "RFC 0097 §D. Emitted when a standing goal stops continuation. Content-free. MUST NOT be emitted unless `capabilities.agents.goals` is advertised.",
|
|
172
|
+
"type": "object",
|
|
173
|
+
"additionalProperties": false,
|
|
174
|
+
"required": ["goalId", "finalState"],
|
|
175
|
+
"properties": {
|
|
176
|
+
"goalId": { "type": "string", "minLength": 1 },
|
|
177
|
+
"finalState": { "type": "string", "enum": ["satisfied", "escalated", "abandoned", "bound-exceeded"], "description": "Terminal state the goal closed in." }
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
"importApplied": {
|
|
182
|
+
"description": "RFC 0098 §D. Emitted when an estate import is applied. Content-free: counts + ref-only — NO item payloads, NO secret values (SECURITY invariant `export-bundle-no-credential-material`). MUST NOT be emitted unless `capabilities.portability` is advertised.",
|
|
183
|
+
"type": "object",
|
|
184
|
+
"additionalProperties": false,
|
|
185
|
+
"required": ["bundleOrigin", "counts"],
|
|
186
|
+
"properties": {
|
|
187
|
+
"bundleOrigin": { "type": "string", "minLength": 1, "description": "The bundle's `source.origin` — informational only." },
|
|
188
|
+
"counts": {
|
|
189
|
+
"type": "object",
|
|
190
|
+
"additionalProperties": false,
|
|
191
|
+
"description": "Per-action item tallies.",
|
|
192
|
+
"properties": {
|
|
193
|
+
"created": { "type": "integer", "minimum": 0 },
|
|
194
|
+
"updated": { "type": "integer", "minimum": 0 },
|
|
195
|
+
"skipped": { "type": "integer", "minimum": 0 },
|
|
196
|
+
"failed": { "type": "integer", "minimum": 0 }
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
"secretsToRebind": { "type": "array", "items": { "type": "string" }, "description": "Provider/ref ids whose secrets must be re-bound at the destination. Refs only — never secret values." }
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
|
|
125
203
|
"connectorAuthorized": {
|
|
126
204
|
"description": "RFC 0047 — emitted when the host acquires (or re-authorizes) a third-party OAuth token on a user's behalf via the `host.oauth` flow. Redaction-safe: carries the credential REFERENCE (RFC 0046), never token material. MUST NOT be emitted unless `capabilities.oauth.supported: true`.",
|
|
127
205
|
"type": "object",
|
|
@@ -423,7 +501,7 @@
|
|
|
423
501
|
"properties": {
|
|
424
502
|
"nodeId": { "type": "string", "minLength": 1 },
|
|
425
503
|
"interruptId": { "type": "string", "minLength": 1 },
|
|
426
|
-
"kind": { "type": "string", "enum": ["approval", "clarification", "external-event", "custom"] },
|
|
504
|
+
"kind": { "type": "string", "enum": ["approval", "clarification", "external-event", "custom", "conversation.start", "conversation.exchange", "conversation.close", "low-confidence"], "description": "RFC 0094 §E — the full kind union `interrupt.md` defines (mirrors suspend-request.schema.json). Conversation kinds are gated on the conversation capability per capabilities.md." },
|
|
427
505
|
"key": { "type": "string", "minLength": 1 }
|
|
428
506
|
},
|
|
429
507
|
"additionalProperties": true
|
|
@@ -608,7 +686,7 @@
|
|
|
608
686
|
"properties": {
|
|
609
687
|
"nodeId": { "type": "string" },
|
|
610
688
|
"interruptId": { "type": "string" },
|
|
611
|
-
"kind": { "type": "string", "enum": ["approval", "clarification", "external-event", "custom"] },
|
|
689
|
+
"kind": { "type": "string", "enum": ["approval", "clarification", "external-event", "custom", "conversation.start", "conversation.exchange", "conversation.close", "low-confidence"], "description": "RFC 0094 §E — the full kind union `interrupt.md` defines (mirrors suspend-request.schema.json). Conversation kinds are gated on the conversation capability per capabilities.md." },
|
|
612
690
|
"resumeValue": {}
|
|
613
691
|
},
|
|
614
692
|
"additionalProperties": true
|
|
@@ -636,12 +714,13 @@
|
|
|
636
714
|
|
|
637
715
|
"outputChunk": {
|
|
638
716
|
"type": "object",
|
|
639
|
-
"description": "Emitted for streaming output (e.g., LLM token chunks). Stream-mode `messages` consumers see these.
|
|
640
|
-
"required": ["nodeId", "chunk"],
|
|
717
|
+
"description": "Emitted for streaming output (e.g., LLM token chunks). Stream-mode `messages` consumers see these. RFC 0094 §D single-sources the `ai.message.chunk` payload here: bare {nodeId, runId, chunk, isLast} is the minimum compliant payload per stream-modes.md §messages (the prior {nodeId, chunk}-only required set was the defective restatement). Tiered metadata per stream-modes.md §messages (S2 closure): `meta` adds Tier 1 typed slots and a Tier 2 provider-pass-through escape hatch.",
|
|
718
|
+
"required": ["nodeId", "runId", "chunk", "isLast"],
|
|
641
719
|
"properties": {
|
|
642
720
|
"nodeId": { "type": "string", "minLength": 1 },
|
|
721
|
+
"runId": { "type": "string", "minLength": 1, "description": "Run this chunk belongs to. Required so multiplexed consumers (mixed-mode streams, fan-in UIs) can route chunks without out-of-band context." },
|
|
643
722
|
"chunk": { "type": "string" },
|
|
644
|
-
"isLast": { "type": "boolean" },
|
|
723
|
+
"isLast": { "type": "boolean", "description": "True for the final chunk of a given AI node call. Required — both reference consumers rely on it for fold termination." },
|
|
645
724
|
"channel": { "type": "string", "description": "Optional sub-stream identifier when a node emits multiple parallel streams." },
|
|
646
725
|
"meta": { "$ref": "#/$defs/_chunkMeta" }
|
|
647
726
|
},
|
|
@@ -25,7 +25,15 @@
|
|
|
25
25
|
"maxLength": 128
|
|
26
26
|
},
|
|
27
27
|
"type": {
|
|
28
|
-
"
|
|
28
|
+
"description": "Event-type discriminator. RFC 0094 §C: either a protocol-owned type from the authoritative `RunEventType` catalog, or a correctly-prefixed vendor-extension event per `host-extensions.md` §\"Vendor-prefixed namespaces\" (dotted name whose first segment is the vendor's short identifier or reverse-DNS label — e.g. `acme.canvas.published` — and is NOT a protocol-owned/registry-reserved prefix). Aligns the schema with `COMPATIBILITY.md` (\"Clients MUST ignore unknown event types\") and `version-negotiation.md` (readers ignore unknown).",
|
|
29
|
+
"anyOf": [
|
|
30
|
+
{ "$ref": "#/$defs/RunEventType" },
|
|
31
|
+
{
|
|
32
|
+
"type": "string",
|
|
33
|
+
"pattern": "^(?!(?:openwop|core|community|vendor|private|local)\\.)[a-z][a-zA-Z0-9_-]*(\\.[a-zA-Z0-9_-]+)+$",
|
|
34
|
+
"description": "Vendor-extension event type: two or more dot-separated segments; the leading segment is the vendor prefix and MUST NOT be one of the protocol-owned / registry-reserved prefixes (`openwop.*`, `core.*`, `community.*`, `vendor.*`, `private.*`, `local.*`) per `host-extensions.md` §\"Canonical prefixes\"."
|
|
35
|
+
}
|
|
36
|
+
]
|
|
29
37
|
},
|
|
30
38
|
"payload": {
|
|
31
39
|
"description": "Event-type-specific payload. Servers MAY validate against per-type schemas; this top-level schema accepts any JSON value.",
|
|
@@ -57,7 +65,8 @@
|
|
|
57
65
|
"maxLength": 128
|
|
58
66
|
}
|
|
59
67
|
},
|
|
60
|
-
"
|
|
68
|
+
"$comment": "RFC 0094 §G + COMPATIBILITY.md §\"Schema closure\": RunEventDoc is a SERVER-EMITTED shape, so it is open (`additionalProperties: true`) — v1.x hosts may add optional fields additively without breaking schema-validating clients. Client-submitted shapes stay closed at their outermost composition.",
|
|
69
|
+
"additionalProperties": true,
|
|
61
70
|
"$defs": {
|
|
62
71
|
"RunEventType": {
|
|
63
72
|
"type": "string",
|
|
@@ -157,7 +166,12 @@
|
|
|
157
166
|
"core.workflowChain.confidence-escalated",
|
|
158
167
|
"connector.authorized",
|
|
159
168
|
"connector.auth_expired",
|
|
160
|
-
"authorization.decided"
|
|
169
|
+
"authorization.decided",
|
|
170
|
+
"proposal.created",
|
|
171
|
+
"proposal.activated",
|
|
172
|
+
"goal.evaluated",
|
|
173
|
+
"goal.closed",
|
|
174
|
+
"import.applied"
|
|
161
175
|
]
|
|
162
176
|
}
|
|
163
177
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://openwop.dev/spec/v1/run-options.schema.json",
|
|
4
4
|
"title": "RunOptions",
|
|
5
|
-
"description": "Per-run parameter overlay supplied by the caller in `POST /v1/runs`. See `run-options.md` for full semantics. Reserved keys are typed; unknown keys in `configurable` pass through to the engine.",
|
|
5
|
+
"description": "Per-run parameter overlay supplied by the caller in `POST /v1/runs`. See `run-options.md` for full semantics. Reserved keys are typed; unknown keys in `configurable` pass through to the engine. RFC 0094 §A: this schema is OPEN at its own root (no `additionalProperties: false`) because it participates in the composed `createRun` request body in `api/openapi.yaml`; the closure (`unevaluatedProperties: false`) lives at that composition site, the only place JSON Schema 2020-12 can express closure over an `allOf`.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"properties": {
|
|
8
8
|
"configurable": {
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
"description": "Free-form caller metadata. Persisted on the run doc; surfaces back via `RunSnapshot.metadata`. Server MAY enforce a serialized-size limit (recommended: 50KB)."
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
|
-
"additionalProperties": false,
|
|
25
24
|
"$defs": {
|
|
26
25
|
"Configurable": {
|
|
27
26
|
"type": "object",
|
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
"waiting-external",
|
|
29
29
|
"completed",
|
|
30
30
|
"failed",
|
|
31
|
+
"cancelling",
|
|
31
32
|
"cancelled"
|
|
32
33
|
],
|
|
33
|
-
"description": "Current run state. `waiting-external` MUST be used when the suspended interrupt's `kind` is `external-event` per `interrupt-profiles.md §openwop-interrupt-external-event` — distinguishes external-event waits from HITL waits at the wire level. Forward-compat: future statuses MAY be added; readers SHOULD treat unknown values as terminal-unknown rather than throw."
|
|
34
|
+
"description": "Current run state. `waiting-external` MUST be used when the suspended interrupt's `kind` is `external-event` per `interrupt-profiles.md §openwop-interrupt-external-event` — distinguishes external-event waits from HITL waits at the wire level. `cancelling` (RFC 0094 §B) is the transitional state between a cancel request being accepted and the terminal `cancelled` — `rest-endpoints.md` and the OpenAPI cancel responses already document the transition; a snapshot read during the cancel cascade carries it. Forward-compat: future statuses MAY be added; readers SHOULD treat unknown values as terminal-unknown rather than throw."
|
|
34
35
|
},
|
|
35
36
|
"owner": {
|
|
36
37
|
"type": "object",
|
|
@@ -76,6 +76,11 @@
|
|
|
76
76
|
"type": "string",
|
|
77
77
|
"enum": ["single-veto", "majority"],
|
|
78
78
|
"default": "single-veto"
|
|
79
|
+
},
|
|
80
|
+
"overrideBypassesQuorum": {
|
|
81
|
+
"type": "boolean",
|
|
82
|
+
"default": false,
|
|
83
|
+
"description": "RFC 0093 §D2 (pins RFC 0051 UQ 2). When `true`, a configured override principal (per `interrupt-profiles.md` §approval-gate `override.requiredRole`) MAY bypass the quorum (`requiredApprovals`) and release the gate alone. Default `false`: an override principal's approval counts as ONE quorum vote; quorum still applies. Optional, additive."
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
86
|
},
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection-pack manifest validity — `connection-packs.md` §Manifest clauses 1/3
|
|
3
|
+
* + `schemas/connection-pack-manifest.schema.json` (RFC 0095 §A).
|
|
4
|
+
*
|
|
5
|
+
* Always-on, server-free schema probe. Exercises the new
|
|
6
|
+
* `connection-pack-manifest.schema.json` with the canonical positive fixture
|
|
7
|
+
* and the kind-discriminator negatives:
|
|
8
|
+
*
|
|
9
|
+
* 1. Positive: the `connection-pack-github` fixture (a complete `kind:
|
|
10
|
+
* "connection"` manifest, MCP reach) validates cleanly.
|
|
11
|
+
* 2. Capability shape: `capabilities.schema.json` declares
|
|
12
|
+
* `connections.packsSupported` (RFC 0095 §C).
|
|
13
|
+
* 3. Negative — kind discriminator: the same manifest with `kind: "node"`
|
|
14
|
+
* is rejected (`const` violation) — the discriminator routes a
|
|
15
|
+
* connection manifest away from the other pack schemas.
|
|
16
|
+
* 4. Negative — kind/contents mixing: a manifest carrying BOTH `provider`
|
|
17
|
+
* AND `nodes[]` is rejected. Surface-level outcome at the registry is
|
|
18
|
+
* `pack_kind_invalid` per `node-packs.md` §"Pack kinds"; schema-level
|
|
19
|
+
* outcome is an `additionalProperties` violation on `nodes`.
|
|
20
|
+
* 5. Negative — non-https token endpoint: `http://` is rejected with a
|
|
21
|
+
* `pattern` violation (clause 3).
|
|
22
|
+
* 6. Positive — a SemVer prerelease `version` (`1.0.0-alpha.1`) is
|
|
23
|
+
* schema-VALID: prerelease *precedence* (clause 6, SemVer §11) is a
|
|
24
|
+
* host resolution concern, not a manifest-shape constraint.
|
|
25
|
+
*
|
|
26
|
+
* Behavioral resolution legs live in `connection-provider-resolution.test.ts`
|
|
27
|
+
* (capability-gated on `capabilities.connections.packsSupported`).
|
|
28
|
+
*
|
|
29
|
+
* @see spec/v1/connection-packs.md
|
|
30
|
+
* @see schemas/connection-pack-manifest.schema.json
|
|
31
|
+
* @see RFCS/0095-connection-packs-portable-provider-definitions.md
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect } from 'vitest';
|
|
35
|
+
import { readFileSync } from 'node:fs';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
38
|
+
import addFormats from 'ajv-formats';
|
|
39
|
+
import type { ErrorObject } from 'ajv';
|
|
40
|
+
import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
|
|
41
|
+
|
|
42
|
+
const SCHEMA_PATH = join(SCHEMAS_DIR, 'connection-pack-manifest.schema.json');
|
|
43
|
+
const FIXTURE_PATH = join(FIXTURES_DIR, 'connection-packs', 'connection-pack-github.json');
|
|
44
|
+
|
|
45
|
+
type Manifest = Record<string, unknown> & {
|
|
46
|
+
provider: Record<string, unknown> & { auth: Record<string, unknown> };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function fixture(): Manifest {
|
|
50
|
+
return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Manifest;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('category: connection-pack manifest validation (RFC 0095 §A)', () => {
|
|
54
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
55
|
+
addFormats(ajv);
|
|
56
|
+
const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
|
|
57
|
+
const validate = ajv.compile(schema);
|
|
58
|
+
|
|
59
|
+
const failsWith = (manifest: unknown, keyword: string): ErrorObject[] => {
|
|
60
|
+
const ok = validate(manifest);
|
|
61
|
+
expect(ok).toBe(false);
|
|
62
|
+
return (validate.errors ?? []).filter((e) => e.keyword === keyword);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
it('positive: the connection-pack-github fixture validates cleanly', () => {
|
|
66
|
+
expect(
|
|
67
|
+
validate(fixture()),
|
|
68
|
+
`connection-packs.md §Manifest clause 1: a well-formed kind:"connection" manifest MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
|
|
69
|
+
).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('capabilities.schema.json declares connections.packsSupported (RFC 0095 §C)', () => {
|
|
73
|
+
const caps = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8')) as {
|
|
74
|
+
properties?: Record<string, { properties?: Record<string, unknown>; required?: string[] }>;
|
|
75
|
+
};
|
|
76
|
+
const connections = caps.properties?.connections;
|
|
77
|
+
expect(connections, 'capabilities.md §connections — the connections block MUST be declared').toBeDefined();
|
|
78
|
+
expect(
|
|
79
|
+
connections?.properties?.packsSupported,
|
|
80
|
+
'RFC 0095 §C — connections.packsSupported MUST be declared',
|
|
81
|
+
).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('negative: the kind discriminator routes other kinds away (kind: "node" rejected)', () => {
|
|
85
|
+
const m = { ...fixture(), kind: 'node' };
|
|
86
|
+
const errs = failsWith(m, 'const');
|
|
87
|
+
expect(
|
|
88
|
+
errs.length,
|
|
89
|
+
'connection-packs.md §Manifest clause 1: kind MUST be the const "connection"',
|
|
90
|
+
).toBeGreaterThan(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('negative: a manifest mixing provider and nodes[] is rejected (pack_kind_invalid at the registry)', () => {
|
|
94
|
+
const m = {
|
|
95
|
+
...fixture(),
|
|
96
|
+
nodes: [{ typeId: 'vendor.acme.x', version: '1.0.0', category: 'data', role: 'pure' }],
|
|
97
|
+
};
|
|
98
|
+
const errs = failsWith(m, 'additionalProperties');
|
|
99
|
+
expect(
|
|
100
|
+
errs.some((e) => (e.params as { additionalProperty?: string }).additionalProperty === 'nodes'),
|
|
101
|
+
'node-packs.md §"Pack kinds": one kind per pack — a foreign `nodes[]` field MUST be rejected (additionalProperties:false)',
|
|
102
|
+
).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('negative: a non-https token endpoint is rejected (clause 3)', () => {
|
|
106
|
+
const m = fixture();
|
|
107
|
+
(m.provider.auth.endpoints as Record<string, string>).token = 'http://example.com/token';
|
|
108
|
+
const errs = failsWith(m, 'pattern');
|
|
109
|
+
expect(
|
|
110
|
+
errs.length,
|
|
111
|
+
'connection-packs.md §Manifest clause 3: auth endpoints MUST be absolute https:// URLs',
|
|
112
|
+
).toBeGreaterThan(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('positive: a SemVer prerelease version is schema-valid (precedence is a host concern, clause 6)', () => {
|
|
116
|
+
const m = { ...fixture(), version: '1.0.0-alpha.1' };
|
|
117
|
+
expect(
|
|
118
|
+
validate(m),
|
|
119
|
+
`connection-packs.md §Manifest clause 6: prerelease ordering is resolution-time SemVer §11, not manifest shape. Errors: ${JSON.stringify(validate.errors)}`,
|
|
120
|
+
).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection packs carry NO credential material — `connection-packs.md`
|
|
3
|
+
* §Manifest clause 2 (RFC 0095 §B.2). Public test for the protocol-tier
|
|
4
|
+
* SECURITY invariant `connection-pack-no-credential-material`.
|
|
5
|
+
*
|
|
6
|
+
* Two layers:
|
|
7
|
+
*
|
|
8
|
+
* A. Always-on, server-free schema probe — every name on the normative
|
|
9
|
+
* minimum blocklist (`clientSecret`, `client_secret`, `apiKey`,
|
|
10
|
+
* `api_key`, `token`, `accessToken`, `refreshToken`, `password`,
|
|
11
|
+
* `privateKey`, `secret`), injected at the manifest root, under
|
|
12
|
+
* `provider`, and under `provider.auth`, is rejected by
|
|
13
|
+
* `connection-pack-manifest.schema.json` (`additionalProperties:false`
|
|
14
|
+
* everywhere — the schema layer never admits a secret-named field).
|
|
15
|
+
* The single normative EXEMPTION — the property named `token` at
|
|
16
|
+
* exactly `provider.auth.endpoints.token` (the OAuth token-endpoint
|
|
17
|
+
* URL) — IS schema-valid.
|
|
18
|
+
*
|
|
19
|
+
* B. Capability-gated behavioral leg — on a host advertising
|
|
20
|
+
* `capabilities.connections.packsSupported: true` that exposes the
|
|
21
|
+
* `POST /v1/host/sample/connection-packs/install` test seam
|
|
22
|
+
* (`host-sample-test-seams.md`), installing a manifest that carries
|
|
23
|
+
* `clientSecret` MUST be rejected with the SPECIFIC error code
|
|
24
|
+
* `connection_pack_credential_material` — not a generic schema-shape
|
|
25
|
+
* error — because clause 2 requires the credential-material scan to
|
|
26
|
+
* run BEFORE generic schema validation. Hosts without the seam
|
|
27
|
+
* soft-skip (404); unadvertised hosts skip via the behavior gate.
|
|
28
|
+
*
|
|
29
|
+
* @see spec/v1/connection-packs.md
|
|
30
|
+
* @see SECURITY/invariants.yaml id: connection-pack-no-credential-material
|
|
31
|
+
* @see RFCS/0095-connection-packs-portable-provider-definitions.md
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect } from 'vitest';
|
|
35
|
+
import { readFileSync } from 'node:fs';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
38
|
+
import addFormats from 'ajv-formats';
|
|
39
|
+
import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
|
|
40
|
+
import { driver } from '../lib/driver.js';
|
|
41
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
42
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
43
|
+
|
|
44
|
+
const SCHEMA_PATH = join(SCHEMAS_DIR, 'connection-pack-manifest.schema.json');
|
|
45
|
+
const FIXTURE_PATH = join(FIXTURES_DIR, 'connection-packs', 'connection-pack-github.json');
|
|
46
|
+
|
|
47
|
+
const BLOCKLIST = [
|
|
48
|
+
'clientSecret',
|
|
49
|
+
'client_secret',
|
|
50
|
+
'apiKey',
|
|
51
|
+
'api_key',
|
|
52
|
+
'token',
|
|
53
|
+
'accessToken',
|
|
54
|
+
'refreshToken',
|
|
55
|
+
'password',
|
|
56
|
+
'privateKey',
|
|
57
|
+
'secret',
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
type Manifest = Record<string, unknown> & {
|
|
61
|
+
provider: Record<string, unknown> & { auth: Record<string, unknown> };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function fixture(): Manifest {
|
|
65
|
+
return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Manifest;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('connection-pack-no-credential-material: schema layer (always-on, server-free)', () => {
|
|
69
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
70
|
+
addFormats(ajv);
|
|
71
|
+
const validate = ajv.compile(JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')));
|
|
72
|
+
|
|
73
|
+
it('every blocklisted property name is schema-rejected at the root, provider, and auth levels', () => {
|
|
74
|
+
for (const name of BLOCKLIST) {
|
|
75
|
+
const atRoot = { ...fixture(), [name]: 'xxx' };
|
|
76
|
+
const atProvider = fixture();
|
|
77
|
+
(atProvider.provider as Record<string, unknown>)[name] = 'xxx';
|
|
78
|
+
const atAuth = fixture();
|
|
79
|
+
(atAuth.provider.auth as Record<string, unknown>)[name] = 'xxx';
|
|
80
|
+
for (const [where, m] of [['root', atRoot], ['provider', atProvider], ['provider.auth', atAuth]] as const) {
|
|
81
|
+
expect(
|
|
82
|
+
validate(m),
|
|
83
|
+
`SECURITY invariant connection-pack-no-credential-material — a property named "${name}" at ${where} MUST NOT validate (additionalProperties:false)`,
|
|
84
|
+
).toBe(false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('the exemption: provider.auth.endpoints.token (the token-endpoint URL) IS valid', () => {
|
|
90
|
+
const m = fixture();
|
|
91
|
+
expect(
|
|
92
|
+
typeof (m.provider.auth.endpoints as Record<string, string>).token,
|
|
93
|
+
'fixture sanity — the github fixture declares the token endpoint',
|
|
94
|
+
).toBe('string');
|
|
95
|
+
expect(
|
|
96
|
+
validate(m),
|
|
97
|
+
`connection-packs.md §Manifest clause 2 — the property named "token" at exactly provider.auth.endpoints.token is the OAuth token-endpoint URL and MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
|
|
98
|
+
).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('connection-pack-no-credential-material: specific rejection code (capability-gated, RFC 0095 §B.2)', () => {
|
|
103
|
+
it('installing a manifest carrying clientSecret is rejected with connection_pack_credential_material', async () => {
|
|
104
|
+
const connections = await readCapabilityFamily<{ packsSupported?: boolean }>('connections');
|
|
105
|
+
if (!behaviorGate('connections.packsSupported', connections?.packsSupported === true)) return;
|
|
106
|
+
|
|
107
|
+
const leaky = fixture();
|
|
108
|
+
(leaky.provider.auth as Record<string, unknown>).clientSecret = 'ghs_conformance_canary';
|
|
109
|
+
const res = await driver.post('/v1/host/sample/connection-packs/install', { manifest: leaky });
|
|
110
|
+
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
111
|
+
|
|
112
|
+
const body = res.json as { installed?: boolean; errors?: Array<{ code?: string }> } | undefined;
|
|
113
|
+
expect(
|
|
114
|
+
body?.installed,
|
|
115
|
+
driver.describe('connection-packs.md §Manifest clause 2', 'a manifest carrying credential material MUST NOT install'),
|
|
116
|
+
).toBe(false);
|
|
117
|
+
expect(
|
|
118
|
+
(body?.errors ?? []).some((e) => e.code === 'connection_pack_credential_material'),
|
|
119
|
+
driver.describe(
|
|
120
|
+
'connection-packs.md §Manifest clause 2',
|
|
121
|
+
'the credential-material scan runs BEFORE generic schema validation — the SPECIFIC code connection_pack_credential_material MUST surface, not a generic shape error',
|
|
122
|
+
),
|
|
123
|
+
).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection-pack reach exclusivity — `connection-packs.md` §Manifest clause 5
|
|
3
|
+
* (RFC 0095 §B.5).
|
|
4
|
+
*
|
|
5
|
+
* Always-on, server-free schema probe. `provider.reach` MUST specify exactly
|
|
6
|
+
* ONE of `mcp` / `openapi` / `integration` — the schema pins this with
|
|
7
|
+
* `minProperties: 1` + `maxProperties: 1` + `additionalProperties: false`:
|
|
8
|
+
*
|
|
9
|
+
* 1. Positive: each of the three reach modes validates alone.
|
|
10
|
+
* 2. Negative — two modes (`mcp` + `openapi`) → `maxProperties` violation.
|
|
11
|
+
* 3. Negative — zero modes (`reach: {}`) → `minProperties` violation.
|
|
12
|
+
* 4. Negative — an unknown mode (`grpc`) → `additionalProperties` violation.
|
|
13
|
+
*
|
|
14
|
+
* @see spec/v1/connection-packs.md
|
|
15
|
+
* @see schemas/connection-pack-manifest.schema.json
|
|
16
|
+
* @see RFCS/0095-connection-packs-portable-provider-definitions.md
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
23
|
+
import addFormats from 'ajv-formats';
|
|
24
|
+
import type { ErrorObject } from 'ajv';
|
|
25
|
+
import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
|
|
26
|
+
|
|
27
|
+
const SCHEMA_PATH = join(SCHEMAS_DIR, 'connection-pack-manifest.schema.json');
|
|
28
|
+
const FIXTURE_PATH = join(FIXTURES_DIR, 'connection-packs', 'connection-pack-github.json');
|
|
29
|
+
|
|
30
|
+
const MCP = { mcp: { server: { url: 'https://api.githubcopilot.com/mcp/', transport: 'http' } } };
|
|
31
|
+
const OPENAPI = { openapi: { ref: 'https://api.github.com/openapi.json' } };
|
|
32
|
+
const INTEGRATION = { integration: { node: 'core.openwop.integration.github' } };
|
|
33
|
+
|
|
34
|
+
type Manifest = Record<string, unknown> & { provider: Record<string, unknown> };
|
|
35
|
+
|
|
36
|
+
function withReach(reach: Record<string, unknown>): Manifest {
|
|
37
|
+
const m = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Manifest;
|
|
38
|
+
m.provider.reach = reach;
|
|
39
|
+
return m;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('connection-pack-reach-exclusive (RFC 0095 §B.5)', () => {
|
|
43
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
44
|
+
addFormats(ajv);
|
|
45
|
+
const validate = ajv.compile(JSON.parse(readFileSync(SCHEMA_PATH, 'utf8')));
|
|
46
|
+
|
|
47
|
+
const failsWith = (manifest: unknown, keyword: string): ErrorObject[] => {
|
|
48
|
+
const ok = validate(manifest);
|
|
49
|
+
expect(ok).toBe(false);
|
|
50
|
+
return (validate.errors ?? []).filter((e) => e.keyword === keyword);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
it('positive: each reach mode validates alone', () => {
|
|
54
|
+
for (const reach of [MCP, OPENAPI, INTEGRATION]) {
|
|
55
|
+
expect(
|
|
56
|
+
validate(withReach(reach)),
|
|
57
|
+
`connection-packs.md §Manifest clause 5: a single reach mode (${Object.keys(reach)[0]}) MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
|
|
58
|
+
).toBe(true);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('negative: two reach modes are rejected (maxProperties:1)', () => {
|
|
63
|
+
const errs = failsWith(withReach({ ...MCP, ...OPENAPI }), 'maxProperties');
|
|
64
|
+
expect(
|
|
65
|
+
errs.length,
|
|
66
|
+
'connection-packs.md §Manifest clause 5: reach MUST specify exactly one of mcp/openapi/integration',
|
|
67
|
+
).toBeGreaterThan(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('negative: an empty reach is rejected (minProperties:1)', () => {
|
|
71
|
+
const errs = failsWith(withReach({}), 'minProperties');
|
|
72
|
+
expect(
|
|
73
|
+
errs.length,
|
|
74
|
+
'connection-packs.md §Manifest clause 5: reach MUST declare a mode — an empty object is invalid',
|
|
75
|
+
).toBeGreaterThan(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('negative: an unknown reach mode is rejected (additionalProperties:false)', () => {
|
|
79
|
+
const errs = failsWith(withReach({ grpc: { url: 'https://example.com' } }), 'additionalProperties');
|
|
80
|
+
expect(
|
|
81
|
+
errs.some((e) => (e.params as { additionalProperty?: string }).additionalProperty === 'grpc'),
|
|
82
|
+
'connection-packs.md §Manifest clause 5: the reach vocabulary is closed (mcp | openapi | integration)',
|
|
83
|
+
).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection-pack write re-consent — `connection-packs.md` §Manifest clause 4
|
|
3
|
+
* (RFC 0095 §B.4) — behavioral.
|
|
4
|
+
*
|
|
5
|
+
* Capability-gated on `capabilities.connections.packsSupported: true`
|
|
6
|
+
* (soft-skips when unadvertised; hard-fails under
|
|
7
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true`). Drives the
|
|
8
|
+
* `POST /v1/host/sample/connection-packs/consent-plan` test seam
|
|
9
|
+
* (`host-sample-test-seams.md`); hosts that haven't wired the seam
|
|
10
|
+
* soft-skip (404).
|
|
11
|
+
*
|
|
12
|
+
* For a `scopeModel: "groups"` oauth2 provider, requesting read AND write
|
|
13
|
+
* scope groups MUST plan write as a SEPARATE consent step — a host MUST NOT
|
|
14
|
+
* bundle write scopes into the initial read authorization (composes with the
|
|
15
|
+
* RFC 0047 write-re-consent pattern):
|
|
16
|
+
*
|
|
17
|
+
* 1. The plan has ≥ 2 steps when both read and write groups are requested.
|
|
18
|
+
* 2. The FIRST (initial) authorization step carries no write scope group.
|
|
19
|
+
* 3. A read-only request plans a single step (no spurious re-consent).
|
|
20
|
+
*
|
|
21
|
+
* @see spec/v1/connection-packs.md
|
|
22
|
+
* @see spec/v1/host-sample-test-seams.md
|
|
23
|
+
* @see RFCS/0095-connection-packs-portable-provider-definitions.md
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { FIXTURES_DIR } from '../lib/paths.js';
|
|
30
|
+
import { driver } from '../lib/driver.js';
|
|
31
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
32
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
33
|
+
|
|
34
|
+
const FIXTURE_PATH = join(FIXTURES_DIR, 'connection-packs', 'connection-pack-github.json');
|
|
35
|
+
|
|
36
|
+
interface ConsentStep {
|
|
37
|
+
groups?: Array<{ key?: string; access?: 'read' | 'write' }>;
|
|
38
|
+
includesWrite?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ConsentPlan {
|
|
42
|
+
steps?: ConsentStep[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('connection-pack-write-reconsent (RFC 0095 §B.4)', () => {
|
|
46
|
+
it('write scope groups are a separate consent step, never bundled into the initial read authorization', async () => {
|
|
47
|
+
const connections = await readCapabilityFamily<{ packsSupported?: boolean }>('connections');
|
|
48
|
+
if (!behaviorGate('connections.packsSupported', connections?.packsSupported === true)) return;
|
|
49
|
+
|
|
50
|
+
const manifest = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Record<string, unknown>;
|
|
51
|
+
await driver.post('/v1/host/sample/connection-packs/install', { manifest });
|
|
52
|
+
|
|
53
|
+
const res = await driver.post('/v1/host/sample/connection-packs/consent-plan', {
|
|
54
|
+
provider: 'github',
|
|
55
|
+
requested: ['read', 'write'],
|
|
56
|
+
});
|
|
57
|
+
if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
|
|
58
|
+
|
|
59
|
+
const plan = res.json as ConsentPlan | undefined;
|
|
60
|
+
const steps = plan?.steps ?? [];
|
|
61
|
+
expect(
|
|
62
|
+
steps.length >= 2,
|
|
63
|
+
driver.describe(
|
|
64
|
+
'connection-packs.md §Manifest clause 4',
|
|
65
|
+
'requesting read + write scope groups MUST plan write as a SEPARATE consent step (≥ 2 steps)',
|
|
66
|
+
),
|
|
67
|
+
).toBe(true);
|
|
68
|
+
const first = steps[0] ?? {};
|
|
69
|
+
const firstHasWrite =
|
|
70
|
+
first.includesWrite === true || (first.groups ?? []).some((g) => g.access === 'write');
|
|
71
|
+
expect(
|
|
72
|
+
firstHasWrite,
|
|
73
|
+
driver.describe(
|
|
74
|
+
'connection-packs.md §Manifest clause 4',
|
|
75
|
+
'the INITIAL authorization step MUST NOT bundle write scopes',
|
|
76
|
+
),
|
|
77
|
+
).toBe(false);
|
|
78
|
+
|
|
79
|
+
const readOnly = await driver.post('/v1/host/sample/connection-packs/consent-plan', {
|
|
80
|
+
provider: 'github',
|
|
81
|
+
requested: ['read'],
|
|
82
|
+
});
|
|
83
|
+
if (readOnly.status !== 404 && readOnly.status !== 403) {
|
|
84
|
+
const roSteps = (readOnly.json as ConsentPlan | undefined)?.steps ?? [];
|
|
85
|
+
expect(
|
|
86
|
+
roSteps.length,
|
|
87
|
+
driver.describe('connection-packs.md §Manifest clause 4', 'a read-only request plans a single consent step'),
|
|
88
|
+
).toBe(1);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|