icp-attestation-client.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
2 days ago
| 1 | /** |
| 2 | * AIR Improvement E — ICP attestation canister client. |
| 3 | * |
| 4 | * Wraps @icp-sdk/core to call the attestation canister from the gateway. |
| 5 | * Uses Secp256k1KeyIdentity derived from ICP_ATTESTATION_KEY env var. |
| 6 | * |
| 7 | * Env vars: |
| 8 | * ICP_ATTESTATION_CANISTER_ID — attestation canister principal (required to enable) |
| 9 | * ICP_ATTESTATION_KEY — 32-byte hex seed for gateway identity (required to store) |
| 10 | * ICP_ATTESTATION_HOST — IC host (default: https://ic0.app) |
| 11 | */ |
| 12 | |
| 13 | import { HttpAgent, Actor } from '@icp-sdk/core/agent'; |
| 14 | import { IDL } from '@icp-sdk/core/candid'; |
| 15 | import { Principal } from '@icp-sdk/core/principal'; |
| 16 | |
| 17 | const DEFAULT_HOST = 'https://ic0.app'; |
| 18 | const DEFAULT_STORE_TIMEOUT_MS = 4000; |
| 19 | const DEFAULT_QUERY_TIMEOUT_MS = 3000; |
| 20 | |
| 21 | // --------------------------------------------------------------------------- |
| 22 | // Candid IDL factory for the attestation canister |
| 23 | // --------------------------------------------------------------------------- |
| 24 | |
| 25 | const AttestationRecord = IDL.Record({ |
| 26 | id: IDL.Text, |
| 27 | action: IDL.Text, |
| 28 | path: IDL.Text, |
| 29 | timestamp: IDL.Text, |
| 30 | content_hash: IDL.Text, |
| 31 | sig: IDL.Text, |
| 32 | seq: IDL.Nat, |
| 33 | stored_at: IDL.Text, |
| 34 | }); |
| 35 | |
| 36 | const StoreInput = IDL.Record({ |
| 37 | id: IDL.Text, |
| 38 | action: IDL.Text, |
| 39 | path: IDL.Text, |
| 40 | timestamp: IDL.Text, |
| 41 | content_hash: IDL.Text, |
| 42 | sig: IDL.Text, |
| 43 | }); |
| 44 | |
| 45 | const StoreResult = IDL.Variant({ |
| 46 | ok: IDL.Record({ seq: IDL.Nat }), |
| 47 | err: IDL.Text, |
| 48 | }); |
| 49 | |
| 50 | const ListResult = IDL.Record({ |
| 51 | records: IDL.Vec(AttestationRecord), |
| 52 | total: IDL.Nat, |
| 53 | }); |
| 54 | |
| 55 | /** @type {import('@icp-sdk/core/candid').InterfaceFactory} */ |
| 56 | const idlFactory = ({ IDL: _IDL }) => { |
| 57 | return IDL.Service({ |
| 58 | storeAttestation: IDL.Func([StoreInput], [StoreResult], []), |
| 59 | getAttestation: IDL.Func([IDL.Text], [IDL.Opt(AttestationRecord)], ['query']), |
| 60 | listAttestations: IDL.Func([IDL.Nat, IDL.Nat], [ListResult], ['query']), |
| 61 | getStats: IDL.Func([], [IDL.Record({ total: IDL.Nat, nextSeq: IDL.Nat })], ['query']), |
| 62 | getAuthorizedCallers: IDL.Func([], [IDL.Vec(IDL.Principal)], ['query']), |
| 63 | setAuthorizedCallers: IDL.Func([IDL.Vec(IDL.Principal)], [], []), |
| 64 | }); |
| 65 | }; |
| 66 | |
| 67 | // --------------------------------------------------------------------------- |
| 68 | // Singleton agent + actor (lazy init) |
| 69 | // --------------------------------------------------------------------------- |
| 70 | |
| 71 | let _identity = null; |
| 72 | let _agent = null; |
| 73 | let _actor = null; |
| 74 | |
| 75 | function getCanisterId() { |
| 76 | const id = process.env.ICP_ATTESTATION_CANISTER_ID; |
| 77 | return id && id.trim().length > 0 ? id.trim() : null; |
| 78 | } |
| 79 | |
| 80 | function getKeyHex() { |
| 81 | const k = process.env.ICP_ATTESTATION_KEY; |
| 82 | return k && k.trim().length >= 64 ? k.trim() : null; |
| 83 | } |
| 84 | |
| 85 | function getHost() { |
| 86 | return (process.env.ICP_ATTESTATION_HOST || DEFAULT_HOST).replace(/\/$/, ''); |
| 87 | } |
| 88 | |
| 89 | /** @returns {boolean} */ |
| 90 | export function isIcpAttestationConfigured() { |
| 91 | return Boolean(getCanisterId() && getKeyHex()); |
| 92 | } |
| 93 | |
| 94 | /** @returns {string|null} */ |
| 95 | export function getAttestationCanisterId() { |
| 96 | return getCanisterId(); |
| 97 | } |
| 98 | |
| 99 | async function getIdentity() { |
| 100 | if (_identity) return _identity; |
| 101 | const keyHex = getKeyHex(); |
| 102 | if (!keyHex) return null; |
| 103 | const { Secp256k1KeyIdentity } = await import('@icp-sdk/core/identity/secp256k1'); |
| 104 | const seed = Uint8Array.from(Buffer.from(keyHex, 'hex')); |
| 105 | _identity = Secp256k1KeyIdentity.fromSecretKey(seed); |
| 106 | return _identity; |
| 107 | } |
| 108 | |
| 109 | async function getAgent() { |
| 110 | if (_agent) return _agent; |
| 111 | const identity = await getIdentity(); |
| 112 | if (!identity) return null; |
| 113 | _agent = await HttpAgent.create({ |
| 114 | identity, |
| 115 | host: getHost(), |
| 116 | }); |
| 117 | return _agent; |
| 118 | } |
| 119 | |
| 120 | async function getActor() { |
| 121 | if (_actor) return _actor; |
| 122 | const agent = await getAgent(); |
| 123 | if (!agent) return null; |
| 124 | const canisterId = getCanisterId(); |
| 125 | if (!canisterId) return null; |
| 126 | _actor = Actor.createActor(idlFactory, { |
| 127 | agent, |
| 128 | canisterId, |
| 129 | }); |
| 130 | return _actor; |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * Get the Principal of the gateway identity (for setAuthorizedCallers setup). |
| 135 | * @returns {Promise<string|null>} |
| 136 | */ |
| 137 | export async function getGatewayPrincipal() { |
| 138 | const identity = await getIdentity(); |
| 139 | if (!identity) return null; |
| 140 | return identity.getPrincipal().toText(); |
| 141 | } |
| 142 | |
| 143 | // --------------------------------------------------------------------------- |
| 144 | // Public API |
| 145 | // --------------------------------------------------------------------------- |
| 146 | |
| 147 | /** |
| 148 | * Anchor an attestation record on the ICP canister. |
| 149 | * |
| 150 | * @param {{ id: string, action: string, path: string, timestamp: string, content_hash: string, sig: string }} record |
| 151 | * @param {{ timeoutMs?: number }} [opts] |
| 152 | * @returns {Promise<{ seq: number } | null>} seq on success, null on failure/timeout |
| 153 | */ |
| 154 | export async function anchorAttestation(record, opts = {}) { |
| 155 | if (_testAnchorFn) return _testAnchorFn(record, opts); |
| 156 | if (!isIcpAttestationConfigured()) return null; |
| 157 | |
| 158 | const timeoutMs = opts.timeoutMs ?? DEFAULT_STORE_TIMEOUT_MS; |
| 159 | |
| 160 | try { |
| 161 | const actor = await getActor(); |
| 162 | if (!actor) return null; |
| 163 | |
| 164 | const result = await Promise.race([ |
| 165 | actor.storeAttestation({ |
| 166 | id: record.id, |
| 167 | action: record.action || '', |
| 168 | path: record.path || '', |
| 169 | timestamp: record.timestamp || '', |
| 170 | content_hash: record.content_hash || '', |
| 171 | sig: record.sig || '', |
| 172 | }), |
| 173 | new Promise((_, reject) => |
| 174 | setTimeout(() => reject(new Error('ICP attestation store timeout')), timeoutMs), |
| 175 | ), |
| 176 | ]); |
| 177 | |
| 178 | if (result && 'ok' in result) { |
| 179 | return { seq: Number(result.ok.seq) }; |
| 180 | } |
| 181 | |
| 182 | if (result && 'err' in result) { |
| 183 | console.error('[icp-attest] canister returned error:', result.err); |
| 184 | return null; |
| 185 | } |
| 186 | |
| 187 | return null; |
| 188 | } catch (e) { |
| 189 | console.error('[icp-attest] anchorAttestation failed:', e?.message || String(e)); |
| 190 | return null; |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | /** |
| 195 | * Query an attestation record from the ICP canister. |
| 196 | * |
| 197 | * @param {string} id |
| 198 | * @param {{ timeoutMs?: number }} [opts] |
| 199 | * @returns {Promise<object|null>} record object or null |
| 200 | */ |
| 201 | export async function queryAttestation(id, opts = {}) { |
| 202 | if (_testQueryFn) return _testQueryFn(id, opts); |
| 203 | if (!getCanisterId()) return null; |
| 204 | |
| 205 | const timeoutMs = opts.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS; |
| 206 | |
| 207 | try { |
| 208 | const actor = await getActor(); |
| 209 | if (!actor) return null; |
| 210 | |
| 211 | const result = await Promise.race([ |
| 212 | actor.getAttestation(id), |
| 213 | new Promise((_, reject) => |
| 214 | setTimeout(() => reject(new Error('ICP attestation query timeout')), timeoutMs), |
| 215 | ), |
| 216 | ]); |
| 217 | |
| 218 | if (result && result.length > 0 && result[0]) { |
| 219 | const r = result[0]; |
| 220 | return { |
| 221 | id: r.id, |
| 222 | action: r.action, |
| 223 | path: r.path, |
| 224 | timestamp: r.timestamp, |
| 225 | content_hash: r.content_hash, |
| 226 | sig: r.sig, |
| 227 | seq: Number(r.seq), |
| 228 | stored_at: r.stored_at, |
| 229 | }; |
| 230 | } |
| 231 | |
| 232 | return null; |
| 233 | } catch (e) { |
| 234 | console.error('[icp-attest] queryAttestation failed:', e?.message || String(e)); |
| 235 | return null; |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * Reset cached agent/actor (for testing or key rotation). |
| 241 | */ |
| 242 | export function resetClient() { |
| 243 | _identity = null; |
| 244 | _agent = null; |
| 245 | _actor = null; |
| 246 | } |
| 247 | |
| 248 | // --------------------------------------------------------------------------- |
| 249 | // Test hooks — allow tests to override anchor/query without fighting ESM |
| 250 | // --------------------------------------------------------------------------- |
| 251 | |
| 252 | let _testAnchorFn = null; |
| 253 | let _testQueryFn = null; |
| 254 | |
| 255 | /** |
| 256 | * Override anchorAttestation / queryAttestation for unit tests. |
| 257 | * Pass null to restore real implementations. |
| 258 | * @param {{ anchor?: Function|null, query?: Function|null }} overrides |
| 259 | */ |
| 260 | export function _setTestOverrides(overrides) { |
| 261 | _testAnchorFn = overrides?.anchor ?? null; |
| 262 | _testQueryFn = overrides?.query ?? null; |
| 263 | } |
| 264 | |
| 265 | /** @internal */ |
| 266 | export function _getTestOverrides() { |
| 267 | return { anchor: _testAnchorFn, query: _testQueryFn }; |
| 268 | } |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
2 days ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
2 days ago