iso-ucan 0.0.1
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/dist/src/capability.d.ts +49 -0
- package/dist/src/capability.d.ts.map +1 -0
- package/dist/src/client.d.ts +47 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/delegation.d.ts +61 -0
- package/dist/src/delegation.d.ts.map +1 -0
- package/dist/src/envelope.d.ts +39 -0
- package/dist/src/envelope.d.ts.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/invocation.d.ts +46 -0
- package/dist/src/invocation.d.ts.map +1 -0
- package/dist/src/policy.d.ts +10 -0
- package/dist/src/policy.d.ts.map +1 -0
- package/dist/src/server.d.ts +5 -0
- package/dist/src/server.d.ts.map +1 -0
- package/dist/src/store.d.ts +49 -0
- package/dist/src/store.d.ts.map +1 -0
- package/dist/src/types.d.ts +187 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/utils.d.ts +136 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/varsig.d.ts +24 -0
- package/dist/src/varsig.d.ts.map +1 -0
- package/package.json +140 -0
- package/readme.md +81 -0
- package/src/capability.js +77 -0
- package/src/client.js +52 -0
- package/src/delegation.js +183 -0
- package/src/envelope.js +88 -0
- package/src/index.js +1 -0
- package/src/invocation.js +227 -0
- package/src/policy.js +272 -0
- package/src/server.ts +24 -0
- package/src/store.js +187 -0
- package/src/types/envelope.ts +131 -0
- package/src/types/policy.ts +100 -0
- package/src/types/varsig.ts +22 -0
- package/src/types.ts +243 -0
- package/src/utils.js +375 -0
- package/src/varsig.js +157 -0
package/src/envelope.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as cbor from '@ipld/dag-cbor'
|
|
2
|
+
import * as varsig from './varsig.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @import { Envelope, PayloadTag, EnvelopeDecodeOptions, EnvelopeEncodeOptions, EnvelopeSignOptions, SignaturePayload, PayloadSpec, DecodedEnvelope, DelegationPayload, InvocationPayload} from './types.js'
|
|
6
|
+
* @import { ByteView } from 'multiformats'
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Version */
|
|
10
|
+
export const VERSION = '1.0.0-rc.1'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
*
|
|
14
|
+
* @param {EnvelopeSignOptions} options
|
|
15
|
+
*/
|
|
16
|
+
export async function sign(options) {
|
|
17
|
+
const { spec, version, enc, signer, payload } = options
|
|
18
|
+
|
|
19
|
+
const payloadTag = /** @type {PayloadTag} */ (
|
|
20
|
+
`ucan/${spec}@${version ?? VERSION}`
|
|
21
|
+
)
|
|
22
|
+
const h = varsig.encode({ enc: enc ?? 'DAG-CBOR', alg: signer.signatureType })
|
|
23
|
+
|
|
24
|
+
const signaturePayload = /** @type {SignaturePayload} */ ({
|
|
25
|
+
h,
|
|
26
|
+
[payloadTag]: payload,
|
|
27
|
+
})
|
|
28
|
+
const signature = await signer.sign(cbor.encode(signaturePayload))
|
|
29
|
+
return {
|
|
30
|
+
signature,
|
|
31
|
+
signaturePayload,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Encode a UCAN envelope
|
|
37
|
+
*
|
|
38
|
+
* @see https://github.com/ucan-wg/spec#envelope
|
|
39
|
+
* @param {EnvelopeEncodeOptions} options
|
|
40
|
+
*
|
|
41
|
+
*/
|
|
42
|
+
export function encode(options) {
|
|
43
|
+
const { signature, signaturePayload } = options
|
|
44
|
+
|
|
45
|
+
const envelope = [signature, signaturePayload]
|
|
46
|
+
|
|
47
|
+
return cbor.encode(envelope)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decode a UCAN envelope
|
|
52
|
+
*
|
|
53
|
+
* @template {PayloadSpec} Spec
|
|
54
|
+
* @see https://github.com/ucan-wg/spec#envelope
|
|
55
|
+
* @param {EnvelopeDecodeOptions} options
|
|
56
|
+
* @returns {DecodedEnvelope<Spec>}
|
|
57
|
+
*/
|
|
58
|
+
export function decode(options) {
|
|
59
|
+
const { envelope } = options
|
|
60
|
+
|
|
61
|
+
const decoded = /** @type typeof cbor.decode<Envelope> **/ (cbor.decode)(
|
|
62
|
+
/** @type {ByteView<Envelope>} */ (envelope)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const [signature, sigPayload] = decoded
|
|
66
|
+
const [_h, payloadTag] = Object.keys(sigPayload)
|
|
67
|
+
const header = sigPayload.h
|
|
68
|
+
/** @type {DelegationPayload | InvocationPayload} */
|
|
69
|
+
const payload = sigPayload[/** @type {PayloadTag} */ (payloadTag)]
|
|
70
|
+
|
|
71
|
+
const [_ucan, specVersion] = payloadTag.split('/')
|
|
72
|
+
const [spec, version] = specVersion.split('@')
|
|
73
|
+
|
|
74
|
+
const { alg, enc } = varsig.decode(header)
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
alg,
|
|
78
|
+
enc,
|
|
79
|
+
signature,
|
|
80
|
+
// Narrow payload type based on Spec
|
|
81
|
+
payload:
|
|
82
|
+
/** @type {Spec extends "dlg" ? DelegationPayload : InvocationPayload} */ (
|
|
83
|
+
payload
|
|
84
|
+
),
|
|
85
|
+
spec: /** @type {Spec} */ (spec),
|
|
86
|
+
version,
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * as Varsig from './varsig.js'
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { parse as didParse } from 'iso-did'
|
|
2
|
+
import { randomBytes } from 'iso-web/crypto'
|
|
3
|
+
import * as Envelope from './envelope.js'
|
|
4
|
+
import {
|
|
5
|
+
assertArgs,
|
|
6
|
+
assertExpiration,
|
|
7
|
+
assertIsValidCommand,
|
|
8
|
+
assertMeta,
|
|
9
|
+
assertNonce,
|
|
10
|
+
cid,
|
|
11
|
+
expOrTtl,
|
|
12
|
+
verifySignature,
|
|
13
|
+
} from './utils.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @import {Delegation} from './delegation.js'
|
|
17
|
+
* @import {CID} from 'multiformats'
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* UCAN Invocation
|
|
22
|
+
*/
|
|
23
|
+
export class Invocation {
|
|
24
|
+
/**
|
|
25
|
+
* @type {Delegation[]}
|
|
26
|
+
*/
|
|
27
|
+
delegations
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @type {CID}
|
|
31
|
+
*/
|
|
32
|
+
cid
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
*
|
|
36
|
+
* @param {import("./types.js").DecodedEnvelope<'inv'>} envelope
|
|
37
|
+
* @param {Uint8Array} bytes
|
|
38
|
+
* @param {CID} cid
|
|
39
|
+
* @param {Delegation[]} delegations
|
|
40
|
+
*/
|
|
41
|
+
constructor(envelope, bytes, cid, delegations) {
|
|
42
|
+
this.envelope = envelope
|
|
43
|
+
this.payload = envelope.payload
|
|
44
|
+
this.bytes = bytes
|
|
45
|
+
this.cid = cid
|
|
46
|
+
this.delegations = delegations
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
*
|
|
51
|
+
* @param {import("./types.js").InvocationFromOptions} options
|
|
52
|
+
*/
|
|
53
|
+
static async from(options) {
|
|
54
|
+
const { bytes, audience } = options
|
|
55
|
+
|
|
56
|
+
const envelope = /** @type {typeof Envelope.decode<"inv">} */ (
|
|
57
|
+
Envelope.decode
|
|
58
|
+
)({ envelope: bytes })
|
|
59
|
+
|
|
60
|
+
if (envelope.spec !== 'inv') {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Invalid envelope type. Expected: Invocation but got: ${envelope.spec}`
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
assertStructure(envelope.payload)
|
|
66
|
+
await verifySignature(
|
|
67
|
+
envelope,
|
|
68
|
+
options.verifierResolver,
|
|
69
|
+
options.didResolver
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
// Audience or subject must match receiver
|
|
73
|
+
if (
|
|
74
|
+
envelope.payload.aud !== audience.did &&
|
|
75
|
+
envelope.payload.sub !== audience.did
|
|
76
|
+
) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`UCAN Invocation audience or subject does not match receiver. Expected: ${audience.did} but got: ${envelope.payload.aud ?? envelope.payload.sub ?? 'null'}`
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Resolve and validate proofs
|
|
83
|
+
|
|
84
|
+
/** @type {Delegation[]} */
|
|
85
|
+
const proofs = []
|
|
86
|
+
for (const proof of envelope.payload.prf) {
|
|
87
|
+
const delegation = await options.resolveProof(proof)
|
|
88
|
+
await delegation.validate(options)
|
|
89
|
+
proofs.push(delegation)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
assertProofs(envelope.payload, proofs)
|
|
93
|
+
|
|
94
|
+
const _cid = await cid(envelope)
|
|
95
|
+
return new Invocation(envelope, bytes, _cid, proofs)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {import("./types.js").InvocationOptions} options
|
|
100
|
+
*/
|
|
101
|
+
static async create(options) {
|
|
102
|
+
const nonce = options.nonce || randomBytes(12)
|
|
103
|
+
|
|
104
|
+
/** @type {import("./types.js").InvocationPayload} */
|
|
105
|
+
const payload = {
|
|
106
|
+
iss: options.iss.toString(),
|
|
107
|
+
aud: options.aud ? options.aud : options.sub,
|
|
108
|
+
sub: options.sub,
|
|
109
|
+
cmd: options.cmd,
|
|
110
|
+
nonce,
|
|
111
|
+
exp: expOrTtl(options),
|
|
112
|
+
args: options.args,
|
|
113
|
+
prf: options.prf.map((p) => p.cid),
|
|
114
|
+
}
|
|
115
|
+
if (options.cause) {
|
|
116
|
+
payload.cause = options.cause
|
|
117
|
+
}
|
|
118
|
+
if (options.meta) {
|
|
119
|
+
payload.meta = options.meta
|
|
120
|
+
}
|
|
121
|
+
if (options.iat) {
|
|
122
|
+
payload.iat = options.iat
|
|
123
|
+
}
|
|
124
|
+
if (options.nbf) {
|
|
125
|
+
payload.nbf = options.nbf
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
assertStructure(payload)
|
|
129
|
+
assertProofs(payload, options.prf)
|
|
130
|
+
|
|
131
|
+
const { signature, signaturePayload } = await Envelope.sign({
|
|
132
|
+
spec: 'inv',
|
|
133
|
+
signer: options.iss,
|
|
134
|
+
payload,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const bytes = Envelope.encode({ signature, signaturePayload })
|
|
138
|
+
|
|
139
|
+
/** @type {import("./types.js").DecodedEnvelope<'inv'>} */
|
|
140
|
+
const envelope = {
|
|
141
|
+
alg: options.iss.signatureType,
|
|
142
|
+
enc: 'DAG-CBOR',
|
|
143
|
+
signature,
|
|
144
|
+
payload,
|
|
145
|
+
spec: 'inv',
|
|
146
|
+
version: Envelope.VERSION,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return new Invocation(envelope, bytes, await cid(envelope), options.prf)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {import('./types.js').InvocationPayload} payload
|
|
155
|
+
*/
|
|
156
|
+
function assertStructure(payload) {
|
|
157
|
+
didParse(payload.iss)
|
|
158
|
+
didParse(payload.sub)
|
|
159
|
+
|
|
160
|
+
if (payload.aud) {
|
|
161
|
+
didParse(payload.aud)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
assertIsValidCommand(payload.cmd)
|
|
165
|
+
assertArgs(payload.args)
|
|
166
|
+
assertMeta(payload.meta)
|
|
167
|
+
assertNonce(payload.nonce)
|
|
168
|
+
assertExpiration(payload.exp)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
*
|
|
173
|
+
* @param {import("./types.js").InvocationPayload} payload
|
|
174
|
+
* @param {Delegation[]} proofs
|
|
175
|
+
*/
|
|
176
|
+
export function assertProofs(payload, proofs) {
|
|
177
|
+
if (payload.prf.length <= 0) {
|
|
178
|
+
throw new Error('UCAN Invocation proofs are required')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (proofs.length !== payload.prf.length) {
|
|
182
|
+
throw new Error("UCAN Invocation couldn't resolve all proofs CIDs")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const lastProof = proofs[proofs.length - 1]
|
|
186
|
+
const issuerDid = didParse(lastProof.envelope.payload.iss).did
|
|
187
|
+
if (issuerDid !== lastProof.envelope.payload.sub) {
|
|
188
|
+
throw new Error('UCAN Invocation root proof is not self-signed')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (let index = 0; index < proofs.length; index++) {
|
|
192
|
+
const current = proofs[index].envelope.payload
|
|
193
|
+
const currentAudience = didParse(current.aud)
|
|
194
|
+
const currentSubject = didParse(current.sub ?? payload.sub)
|
|
195
|
+
|
|
196
|
+
const nextProof = proofs[index + 1]
|
|
197
|
+
/** @type {import("./types.js").Payload} */
|
|
198
|
+
let next
|
|
199
|
+
if (nextProof) {
|
|
200
|
+
next = nextProof.envelope.payload
|
|
201
|
+
} else {
|
|
202
|
+
next = payload
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const nextIssuer = didParse(next.iss)
|
|
206
|
+
const nextSubject = didParse(next.sub ?? payload.sub)
|
|
207
|
+
if (nextIssuer.did !== currentAudience.did) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`UCAN Invocation principal alignment mismatch, expected ${currentAudience.toString()} but got ${nextIssuer.toString()}`
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (nextSubject.did !== currentSubject.did) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`UCAN Invocation subject alignment mismatch, expected ${currentSubject.toString()} but got ${nextSubject.toString()}`
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!next.cmd.startsWith(current.cmd)) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`UCAN Invocation command mismatch, expected ${current.cmd} to be a broader than ${next.cmd}`
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return true
|
|
227
|
+
}
|
package/src/policy.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a selector string against a data object, inspired by jq.
|
|
3
|
+
*
|
|
4
|
+
* @param {any} data The data to select from.
|
|
5
|
+
* @param {string} selector The selector string (e.g., ".foo[0].bar").
|
|
6
|
+
* @returns {any} The selected value, or null if the selection fails at an optional part.
|
|
7
|
+
* @throws {Error} If the selection fails at a non-optional part.
|
|
8
|
+
*/
|
|
9
|
+
function resolveSelector(data, selector) {
|
|
10
|
+
if (selector === '.') {
|
|
11
|
+
return data
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// This regex is a simplified parser for the selector syntax.
|
|
15
|
+
const partsRegex = /(\.[\w_]+|\[-?\d*:-?\d*\]|\[-?\d+\]|\[\]|\?)/g
|
|
16
|
+
const parts = selector.match(partsRegex) || []
|
|
17
|
+
|
|
18
|
+
let currentData = data
|
|
19
|
+
|
|
20
|
+
for (let part of parts) {
|
|
21
|
+
let isOptional = false
|
|
22
|
+
if (part.endsWith('?')) {
|
|
23
|
+
isOptional = true
|
|
24
|
+
part = part.slice(0, -1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
if (currentData === undefined || currentData === null) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Cannot process part "${part}" on null or undefined value.`
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (part.startsWith('.')) {
|
|
35
|
+
const key = part.substring(1)
|
|
36
|
+
if (typeof currentData !== 'object' || Array.isArray(currentData)) {
|
|
37
|
+
throw new Error(`Cannot access key "${key}" on non-object.`)
|
|
38
|
+
}
|
|
39
|
+
currentData = currentData[key]
|
|
40
|
+
} else if (part === '[]') {
|
|
41
|
+
if (Array.isArray(currentData)) {
|
|
42
|
+
// No-op for arrays, the quantifier will iterate over it.
|
|
43
|
+
} else if (typeof currentData === 'object') {
|
|
44
|
+
currentData = Object.values(currentData)
|
|
45
|
+
} else {
|
|
46
|
+
throw new Error('Cannot extract values from non-collection.')
|
|
47
|
+
}
|
|
48
|
+
} else if (part.startsWith('[') && part.includes(':')) {
|
|
49
|
+
// Slice
|
|
50
|
+
if (!Array.isArray(currentData)) {
|
|
51
|
+
throw new Error('Slice can only be applied to arrays.')
|
|
52
|
+
}
|
|
53
|
+
const [start, end] = part
|
|
54
|
+
.substring(1, part.length - 1)
|
|
55
|
+
.split(':')
|
|
56
|
+
.map((s) => (s ? Number.parseInt(s, 10) : undefined))
|
|
57
|
+
currentData = currentData.slice(start, end)
|
|
58
|
+
} else if (part.startsWith('[')) {
|
|
59
|
+
// Index
|
|
60
|
+
if (!Array.isArray(currentData)) {
|
|
61
|
+
throw new Error('Index can only be applied to arrays.')
|
|
62
|
+
}
|
|
63
|
+
const index = Number.parseInt(part.substring(1, part.length - 1), 10)
|
|
64
|
+
currentData =
|
|
65
|
+
currentData[index < 0 ? currentData.length + index : index]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (currentData === undefined) {
|
|
69
|
+
throw new Error(`Path segment returned undefined: ${part}`)
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
if (isOptional) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
// Re-throw if not optional, to be caught by evaluateStatement
|
|
76
|
+
throw e
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return currentData
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Performs a deep equality check between two values.
|
|
85
|
+
* @param {any} a
|
|
86
|
+
* @param {any} b
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
function deepEqual(a, b) {
|
|
90
|
+
if (a === b) return true
|
|
91
|
+
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
|
92
|
+
if (a.constructor !== b.constructor) return false
|
|
93
|
+
if (Array.isArray(a)) {
|
|
94
|
+
if (a.length !== b.length) return false
|
|
95
|
+
for (let i = 0; i < a.length; i++) {
|
|
96
|
+
if (!deepEqual(a[i], b[i])) return false
|
|
97
|
+
}
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
const keys = Object.keys(a)
|
|
101
|
+
if (keys.length !== Object.keys(b).length) return false
|
|
102
|
+
for (const key of keys) {
|
|
103
|
+
// biome-ignore lint/suspicious/noPrototypeBuiltins: needed
|
|
104
|
+
if (!b.hasOwnProperty(key) || !deepEqual(a[key], b[key])) return false
|
|
105
|
+
}
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Converts a UCAN glob pattern to a regular expression.
|
|
113
|
+
* @param {string} pattern
|
|
114
|
+
* @returns {RegExp}
|
|
115
|
+
*/
|
|
116
|
+
function likeRegex(pattern) {
|
|
117
|
+
// Convert UCAN glob pattern to a regular expression.
|
|
118
|
+
// The glob pattern has `*` as a wildcard (zero or more chars)
|
|
119
|
+
// and `\*` as an escape for a literal asterisk.
|
|
120
|
+
let regexString = '^'
|
|
121
|
+
let i = 0
|
|
122
|
+
while (i < pattern.length) {
|
|
123
|
+
const char = pattern[i]
|
|
124
|
+
|
|
125
|
+
if (char === '\\') {
|
|
126
|
+
// Handle escape sequence
|
|
127
|
+
if (i + 1 < pattern.length) {
|
|
128
|
+
const nextChar = pattern[i + 1]
|
|
129
|
+
if (nextChar === '*') {
|
|
130
|
+
// This is an escaped asterisk `\*`. Match a literal `*`.
|
|
131
|
+
regexString += '\\*'
|
|
132
|
+
i += 2 // Move past both `\` and `*`
|
|
133
|
+
} else {
|
|
134
|
+
// This is another escape, like `\\`. Treat the `\` as a literal character.
|
|
135
|
+
regexString += '\\\\'
|
|
136
|
+
i++
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// Trailing backslash. Treat as a literal `\`.
|
|
140
|
+
regexString += '\\\\'
|
|
141
|
+
i++
|
|
142
|
+
}
|
|
143
|
+
} else if (char === '*') {
|
|
144
|
+
// This is a wildcard `*`. Match zero or more characters.
|
|
145
|
+
regexString += '.*'
|
|
146
|
+
i++
|
|
147
|
+
} else {
|
|
148
|
+
// This is a regular character. Escape it if it's a special regex metacharacter.
|
|
149
|
+
regexString += char.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
150
|
+
i++
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
regexString += '$'
|
|
154
|
+
|
|
155
|
+
return new RegExp(regexString)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Evaluates a single policy statement against the invocation args.
|
|
160
|
+
*
|
|
161
|
+
* @template Args
|
|
162
|
+
* @param {Args} args The invocation arguments.
|
|
163
|
+
* @param {import("./types.js").Statement<Args>} statement The policy statement.
|
|
164
|
+
* @returns {boolean} The result of the evaluation.
|
|
165
|
+
*/
|
|
166
|
+
function evaluateStatement(args, statement) {
|
|
167
|
+
const [op, ...params] = statement
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
switch (op) {
|
|
171
|
+
// Comparisons
|
|
172
|
+
case '==': {
|
|
173
|
+
const [selector, value] = /** @type {[string, unknown]} */ (params)
|
|
174
|
+
|
|
175
|
+
return deepEqual(resolveSelector(args, selector), value)
|
|
176
|
+
}
|
|
177
|
+
case '!=': {
|
|
178
|
+
const [selector, value] = /** @type {[string, unknown]} */ (params)
|
|
179
|
+
return !deepEqual(resolveSelector(args, selector), value)
|
|
180
|
+
}
|
|
181
|
+
case '>':
|
|
182
|
+
case '>=':
|
|
183
|
+
case '<':
|
|
184
|
+
case '<=': {
|
|
185
|
+
const [selector, value] = /** @type {[string, number]} */ (params)
|
|
186
|
+
const selected = resolveSelector(args, selector)
|
|
187
|
+
if (typeof selected !== 'number' || typeof value !== 'number')
|
|
188
|
+
return false
|
|
189
|
+
if (op === '>') return selected > value
|
|
190
|
+
if (op === '>=') return selected >= value
|
|
191
|
+
if (op === '<') return selected < value
|
|
192
|
+
if (op === '<=') return selected <= value
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
// Glob Matching
|
|
196
|
+
case 'like': {
|
|
197
|
+
const [selector, pattern] = /** @type {[string, string]} */ (params)
|
|
198
|
+
const selected = resolveSelector(args, selector)
|
|
199
|
+
if (typeof selected !== 'string') return false
|
|
200
|
+
const regex = likeRegex(pattern)
|
|
201
|
+
return regex.test(selected)
|
|
202
|
+
}
|
|
203
|
+
// Connectives
|
|
204
|
+
case 'and': {
|
|
205
|
+
const [statements] = /** @type {[import("./types.js").Statement[]]} */ (
|
|
206
|
+
params
|
|
207
|
+
)
|
|
208
|
+
return statements.every((stmt) => evaluateStatement(args, stmt))
|
|
209
|
+
}
|
|
210
|
+
case 'or': {
|
|
211
|
+
const [statements] = /** @type {[import("./types.js").Statement[]]} */ (
|
|
212
|
+
params
|
|
213
|
+
)
|
|
214
|
+
if (statements.length === 0) return true
|
|
215
|
+
return statements.some((stmt) => evaluateStatement(args, stmt))
|
|
216
|
+
}
|
|
217
|
+
case 'not': {
|
|
218
|
+
const [statement] = /** @type {[import("./types.js").Statement]} */ (
|
|
219
|
+
params
|
|
220
|
+
)
|
|
221
|
+
return !evaluateStatement(args, statement)
|
|
222
|
+
}
|
|
223
|
+
// Quantification
|
|
224
|
+
case 'all':
|
|
225
|
+
case 'any': {
|
|
226
|
+
const [selector, innerStmt] =
|
|
227
|
+
/** @type {[string, import("./types.js").Statement]} */ (params)
|
|
228
|
+
let collection = resolveSelector(args, selector)
|
|
229
|
+
|
|
230
|
+
if (innerStmt[0] === 'any' || innerStmt[0] === 'all') {
|
|
231
|
+
return evaluateStatement(collection, innerStmt)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (
|
|
235
|
+
collection &&
|
|
236
|
+
typeof collection === 'object' &&
|
|
237
|
+
!Array.isArray(collection)
|
|
238
|
+
) {
|
|
239
|
+
collection = Object.values(collection)
|
|
240
|
+
}
|
|
241
|
+
if (!Array.isArray(collection)) return false
|
|
242
|
+
if (collection.length === 0) return true
|
|
243
|
+
if (op === 'all') {
|
|
244
|
+
return collection.every((item) => {
|
|
245
|
+
return evaluateStatement(item, innerStmt)
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
// any
|
|
249
|
+
return collection.some((item) => evaluateStatement(item, innerStmt))
|
|
250
|
+
}
|
|
251
|
+
default:
|
|
252
|
+
return false // Unknown operator
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
// As per the spec, if a selector cannot be resolved, the statement MUST return false.
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Validates invocation arguments against a UCAN policy.
|
|
263
|
+
*
|
|
264
|
+
* @template {Record<string, unknown>} Args
|
|
265
|
+
* @param {Args} args The arguments of the eventual invocation.
|
|
266
|
+
* @param {import("./types.js").Policy<Args>} policy An array of policy statements.
|
|
267
|
+
* @returns {boolean} True if the args are valid according to the policy, false otherwise.
|
|
268
|
+
*/
|
|
269
|
+
export function validate(args, policy) {
|
|
270
|
+
// The top-level policy is an implicit 'and'.
|
|
271
|
+
return policy.every((statement) => evaluateStatement(args, statement))
|
|
272
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
2
|
+
import type { Capability } from './capability.js'
|
|
3
|
+
import type { RouteHandlerOptions, RouteOptions, RouteOutput } from './types.js'
|
|
4
|
+
|
|
5
|
+
export function route<Cap extends Capability<StandardSchemaV1>, Output>(
|
|
6
|
+
options: RouteOptions<Cap, Output>
|
|
7
|
+
): RouteOutput<Cap, Output> {
|
|
8
|
+
return {
|
|
9
|
+
cap: options.capability,
|
|
10
|
+
fn: async ({ request, issuer, invocation, store }: RouteHandlerOptions) => {
|
|
11
|
+
const { capability, handler } = options
|
|
12
|
+
|
|
13
|
+
const args = await capability.validate(invocation.payload.args)
|
|
14
|
+
if (args.issues) {
|
|
15
|
+
throw new Error(args.issues.map((issue) => issue.message).join(', '))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// TODO: handle failure
|
|
19
|
+
const result = await handler({ args: args.value, store })
|
|
20
|
+
|
|
21
|
+
return result
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
}
|