native-oauth-provider.mjs
602 lines 24.7 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Native client OAuth 2.1 provider for the Knowtation companion app.
3 *
4 * Implements changes C1–C6 from docs/COMPANION-APP-OAUTH-SERVERSIDE-GATE.md §6:
5 *
6 * C1 – Mints the web-session JWT (issueToken shape: {sub, provider, id, name, role})
7 * for native loopback clients. The `mcp_access` path is untouched.
8 * C2 – Refresh is backed by refresh-token-core (rotation + reuse→family-revoke).
9 * The new refresh token is delivered in the response body (not a cookie), since
10 * the companion is not a browser.
11 * C3 – Emits `iss` = issuerUrl on the loopback redirect (RFC 9207 §2 mix-up defense).
12 * Value is exactly the `issuer` advertised in the discovery metadata.
13 * C4 – Pending auth codes are stored in native-as-store.mjs (survive process restart).
14 * Refresh tokens use the same durable gateway refresh store as web sessions.
15 * C5 – Validates `redirect_uri` at token exchange per RFC 6749 §4.1.3: the value in
16 * the token request MUST exactly equal the one bound at authorization.
17 * C6 – Scope ceiling guard: never issues a superset of scopesForRole(role). Unknown
18 * or missing role → member ceiling ([vault:read, vault:write]), fail-closed.
19 *
20 * Endpoints (mounted by server.mjs at /api/v1/auth/native):
21 * GET /.well-known/oauth-authorization-server RFC 8414 discovery metadata
22 * POST /register RFC 7591 dynamic client registration
23 * GET /authorize RFC 6749 PKCE authorization start
24 * POST /token RFC 6749 code exchange + refresh
25 * POST /revoke RFC 7009 token revocation
26 *
27 * Security invariants enforced here:
28 * - Only loopback redirect URIs (127.0.0.1 or [::1]) accepted at registration and
29 * authorization (RFC 8252 §7.3, §8.3). Non-loopback → rejected, fail-closed.
30 * - PKCE S256 required at every authorization. `plain` method rejected.
31 * - redirect_uri equality check at code exchange (RFC 6749 §4.1.3).
32 * - Scope ceiling applied at code exchange AND on every refresh rotation (role may
33 * have changed since last login).
34 * - No secret (SESSION_SECRET, JWT, refresh token, code, verifier) appears in any
35 * log line, error body, or redirect URL.
36 * - mcp_access clients (Claude Desktop etc.) are completely unaffected by this module.
37 *
38 * D-SS.4 SDK verification (docs §4): @modelcontextprotocol/sdk ^1.27.1 accepts any
39 * loopback port at registration (no port filtering in OAuthClientMetadataSchema) and
40 * performs exact-match at /authorize against registered redirect_uris — consistent with
41 * RFC 8252 §7.3 ("allow any port") across registrations and exact-match within one flow.
42 * The companion binds one ephemeral port per attempt, derives its redirect_uri from it,
43 * and presents that same value at authorization AND token exchange, satisfying both.
44 */
45
46 import crypto from 'node:crypto';
47 import { createHash } from 'node:crypto';
48 import express from 'express';
49 import {
50 savePendingCode,
51 bindUserToCode,
52 consumePendingCode,
53 } from './native-as-store.mjs';
54
55 /** Access token lifetime. Shorter than the web session for defense-in-depth on native. */
56 const NATIVE_TOKEN_EXPIRY_SECONDS = 15 * 60; // 15 minutes
57
58 /** Refresh token per-token inactivity TTL. Aligns with DEFAULT_TOKEN_TTL_MS in refresh-token-core. */
59 const NATIVE_REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
60
61 /** Max registered clients before evicting the oldest. */
62 const MAX_NATIVE_CLIENTS = 200;
63
64 // ─── Helpers ─────────────────────────────────────────────────────────────────
65
66 /**
67 * Validate that `uri` is an RFC 8252 §7.3 loopback literal.
68 * Accepts http://127.0.0.1:<port>/... and http://[::1]:<port>/...
69 * Rejects: `localhost` hostname (DNS-resolvable, not a literal), non-HTTP schemes,
70 * non-loopback hosts, missing port, or any URI that cannot be parsed.
71 * Exported for tests.
72 *
73 * @param {string} uri
74 * @returns {boolean}
75 */
76 export function isLoopbackUri(uri) {
77 try {
78 const url = new URL(uri);
79 if (url.protocol !== 'http:') return false;
80 const h = url.hostname;
81 // Accept the IPv4 and IPv6 loopback literals only.
82 // RFC 8252 §8.3 explicitly prohibits the use of `localhost` because it can
83 // be hijacked via /etc/hosts or local DNS. We enforce the same restriction.
84 return h === '127.0.0.1' || h === '[::1]' || h === '::1';
85 } catch (_) {
86 return false;
87 }
88 }
89
90 /**
91 * Compute SHA-256(verifier) as base64url — the S256 PKCE challenge method.
92 * @param {string} verifier
93 * @returns {string}
94 */
95 function _sha256Base64url(verifier) {
96 return createHash('sha256').update(String(verifier)).digest('base64url');
97 }
98
99 /**
100 * Map a refresh-token-core failure reason to a stable error code + message.
101 * Aligned to the codes in hub/auth-session.mjs `refreshError` so callers and
102 * tests can use the same constants across both endpoints.
103 *
104 * @param {string} reason
105 * @returns {{ code: string, message: string }}
106 */
107 function _nativeRefreshError(reason) {
108 switch (reason) {
109 case 'reuse':
110 return { code: 'REFRESH_REUSE', message: 'Session was invalidated. Please sign in again.' };
111 case 'expired':
112 return { code: 'REFRESH_EXPIRED', message: 'Session expired. Please sign in again.' };
113 case 'revoked':
114 return { code: 'REFRESH_REVOKED', message: 'Session was revoked. Please sign in again.' };
115 default:
116 return { code: 'UNAUTHORIZED', message: 'Invalid session.' };
117 }
118 }
119
120 // ─── Scope ceiling (C6) ──────────────────────────────────────────────────────
121
122 /**
123 * Apply the scope ceiling for a native client grant (C6).
124 *
125 * Rules:
126 * - If `requested` is non-empty, return only the intersection with `ceiling`.
127 * - If `requested` is empty/absent, return the full ceiling.
128 * - The ceiling is always `scopesForRole(role)` and is never exceeded.
129 * - Unknown/missing role → member ceiling, enforced by the injected `grantedScopes`.
130 *
131 * Exported for unit tests.
132 *
133 * @param {string[]} requested - scopes requested by the client
134 * @param {string[]} ceiling - maximum scopes allowed (from scopesForRole)
135 * @returns {string[]}
136 */
137 export function applyScopeCeiling(requested, ceiling) {
138 if (!Array.isArray(requested) || requested.length === 0) return [...ceiling];
139 return requested.filter((s) => Array.isArray(ceiling) && ceiling.includes(s));
140 }
141
142 // ─── In-memory client store ───────────────────────────────────────────────────
143
144 /**
145 * In-memory store for registered native OAuth clients.
146 *
147 * Client registrations are session-scoped: the companion re-registers on each auth
148 * attempt and registrations need not survive process restart (C4 mandates durable
149 * state only for pending codes and refresh records). An in-memory store is therefore
150 * appropriate — it is also simpler and avoids a durable write on every registration.
151 *
152 * Security: only loopback redirect URIs are accepted. Any non-loopback URI in
153 * `redirect_uris` causes the entire registration to be rejected (fail-closed, C6
154 * spirit + RFC 8252 §8.3).
155 */
156 class NativeClientStore {
157 constructor() {
158 /** @type {Map<string, object>} */
159 this._clients = new Map();
160 }
161
162 /**
163 * @param {string} clientId
164 * @returns {object|undefined}
165 */
166 getClient(clientId) {
167 return this._clients.get(clientId);
168 }
169
170 /**
171 * Register a new native client after validating that every redirect_uri is a
172 * loopback literal.
173 *
174 * @param {{ redirect_uris?: string[], [key: string]: unknown }} meta
175 * @returns {object} full client record (includes generated client_id)
176 * @throws {Error} if redirect_uris is missing/empty or contains a non-loopback URI
177 */
178 registerClient(meta) {
179 const uris = Array.isArray(meta.redirect_uris) ? meta.redirect_uris : [];
180 if (uris.length === 0) {
181 const e = new Error('redirect_uris is required for native clients');
182 e.code = 'invalid_client_metadata';
183 throw e;
184 }
185 for (const uri of uris) {
186 if (!isLoopbackUri(uri)) {
187 const e = new Error(`redirect_uri must be a loopback literal (127.0.0.1 or [::1]): ${uri}`);
188 e.code = 'invalid_redirect_uri';
189 throw e;
190 }
191 }
192
193 // Evict the oldest registration when at capacity.
194 if (this._clients.size >= MAX_NATIVE_CLIENTS) {
195 let oldest = null;
196 let oldestTime = Infinity;
197 for (const [id, c] of this._clients) {
198 if (c.client_id_issued_at < oldestTime) {
199 oldest = id;
200 oldestTime = c.client_id_issued_at;
201 }
202 }
203 if (oldest) this._clients.delete(oldest);
204 }
205
206 const clientId = crypto.randomUUID();
207 const now = Math.floor(Date.now() / 1000);
208 const full = {
209 ...meta,
210 client_id: clientId,
211 client_id_issued_at: now,
212 // Native clients are always public (no secret); enforce this regardless of
213 // what the client requested.
214 token_endpoint_auth_method: 'none',
215 grant_types: ['authorization_code', 'refresh_token'],
216 response_types: ['code'],
217 };
218 this._clients.set(clientId, full);
219 return full;
220 }
221 }
222
223 // ─── Router factory ───────────────────────────────────────────────────────────
224
225 /**
226 * Create the native OAuth 2.1 Express router and the `completeNativeAuthorization`
227 * callback used by server.mjs after the IDP (Google/GitHub) redirects back.
228 *
229 * @param {{
230 * baseUrl: string,
231 * loginUrl?: string,
232 * issueAccessToken: (sub: string) => (string | Promise<string>),
233 * grantedScopes: (sub: string) => string[],
234 * refreshStore: {
235 * issue: (sub: string, opts?: object) => Promise<{ token: string, id: string, familyId: string }>,
236 * rotate: (token: string, opts?: object) => Promise<{ ok: boolean, token?: string, sub?: string, reason?: string }>,
237 * revoke: (token: string) => Promise<{ revoked: boolean, sub: string|null }>,
238 * },
239 * }} opts
240 * @returns {{
241 * router: import('express').Router,
242 * completeNativeAuthorization: (nativeStateBase64: string, userId: string, res: import('express').Response) => Promise<void>,
243 * }}
244 */
245 export function createNativeOAuthRouter(opts) {
246 const baseUrl = opts.baseUrl.replace(/\/$/, '');
247
248 /**
249 * The native AS issuer identifier. Used as `iss` in redirects (C3) and as the
250 * `issuer` field in discovery metadata. Must be stable and HTTPS on the gateway host.
251 * On localhost/dev the MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL env flag allows HTTP.
252 */
253 const issuerUrl = `${baseUrl}/api/v1/auth/native`;
254
255 const loginUrl = (opts.loginUrl || `${baseUrl}/auth/login`).replace(/\/$/, '');
256
257 const clientStore = new NativeClientStore();
258 const router = express.Router();
259
260 // ── Discovery (RFC 8414) ──────────────────────────────────────────────────
261
262 router.get('/.well-known/oauth-authorization-server', (_req, res) => {
263 res.set('Cache-Control', 'public, max-age=3600');
264 res.json({
265 issuer: issuerUrl,
266 authorization_endpoint: `${issuerUrl}/authorize`,
267 token_endpoint: `${issuerUrl}/token`,
268 registration_endpoint: `${issuerUrl}/register`,
269 revocation_endpoint: `${issuerUrl}/revoke`,
270 response_types_supported: ['code'],
271 grant_types_supported: ['authorization_code', 'refresh_token'],
272 code_challenge_methods_supported: ['S256'],
273 token_endpoint_auth_methods_supported: ['none'],
274 scopes_supported: ['vault:read', 'vault:write'],
275 });
276 });
277
278 // ── Dynamic client registration (RFC 7591) ────────────────────────────────
279
280 router.post('/register', express.json({ limit: '16kb' }), (req, res) => {
281 res.set('Cache-Control', 'no-store');
282 let client;
283 try {
284 client = clientStore.registerClient(req.body || {});
285 } catch (e) {
286 const errorCode = e.code || 'invalid_client_metadata';
287 // Do NOT reflect the URI back in the error — it may be a probe for injection.
288 return res.status(400).json({
289 error: errorCode,
290 error_description: 'redirect_uris must be loopback literals (http://127.0.0.1 or http://[::1])',
291 });
292 }
293 return res.status(201).json(client);
294 });
295
296 // ── Authorization endpoint (RFC 6749 §4.1.1 + RFC 7636) ─────────────────
297
298 router.get('/authorize', async (req, res) => {
299 res.set('Cache-Control', 'no-store');
300 const q = req.query;
301
302 // ─ Mandatory parameter validation ─
303 if (
304 !q.client_id ||
305 !q.redirect_uri ||
306 !q.code_challenge ||
307 q.code_challenge_method !== 'S256'
308 ) {
309 return res.status(400).json({
310 error: 'invalid_request',
311 error_description: 'client_id, redirect_uri, code_challenge, and code_challenge_method=S256 are required',
312 });
313 }
314
315 const client = clientStore.getClient(String(q.client_id));
316 if (!client) {
317 return res.status(400).json({ error: 'invalid_client', error_description: 'Unknown client_id' });
318 }
319
320 const redirectUriStr = String(q.redirect_uri);
321
322 // Exact-match against registered URIs (SDK behavior; port included in match).
323 if (!Array.isArray(client.redirect_uris) || !client.redirect_uris.includes(redirectUriStr)) {
324 return res.status(400).json({ error: 'invalid_request', error_description: 'Unregistered redirect_uri' });
325 }
326 // Double-check loopback requirement at authorization time (defense-in-depth: client
327 // store already enforced this at registration, but guard again here, fail-closed).
328 if (!isLoopbackUri(redirectUriStr)) {
329 return res.status(400).json({ error: 'invalid_request', error_description: 'redirect_uri must be a loopback literal' });
330 }
331
332 const requestedScopes = q.scope
333 ? String(q.scope).split(' ').filter(Boolean)
334 : [];
335
336 const code = crypto.randomUUID();
337
338 try {
339 await savePendingCode(code, {
340 clientId: String(q.client_id),
341 codeChallenge: String(q.code_challenge),
342 redirectUri: redirectUriStr,
343 state: q.state ? String(q.state) : null,
344 scopes: requestedScopes,
345 });
346 } catch (_) {
347 return res.status(503).json({
348 error: 'server_error',
349 error_description: 'Authorization service temporarily unavailable',
350 });
351 }
352
353 // Encode state for round-trip through the IDP (Google/GitHub).
354 // The `native_state` carries enough context for completeNativeAuthorization to
355 // find and bind the pending record without exposing the code challenge.
356 const nativeState = Buffer.from(
357 JSON.stringify({
358 code,
359 clientId: String(q.client_id),
360 redirectUri: redirectUriStr,
361 state: q.state ? String(q.state) : null,
362 })
363 ).toString('base64url');
364
365 const loginTarget = new URL(loginUrl);
366 loginTarget.searchParams.set('provider', 'google');
367 loginTarget.searchParams.set('native_state', nativeState);
368 return res.redirect(loginTarget.toString());
369 });
370
371 // ── Token endpoint (RFC 6749 §4.1.3 + §6) ────────────────────────────────
372
373 router.post(
374 '/token',
375 express.urlencoded({ extended: false }),
376 express.json({ limit: '16kb' }),
377 async (req, res) => {
378 res.set('Cache-Control', 'no-store');
379 const body = req.body || {};
380 const grantType = body.grant_type;
381
382 // ─ Authenticate the client (native clients are always public — no secret) ─
383 const clientId = body.client_id;
384 const client = clientId ? clientStore.getClient(String(clientId)) : null;
385 if (!client) {
386 return res.status(401).json({
387 error: 'invalid_client',
388 error_description: 'Unknown or missing client_id',
389 });
390 }
391
392 // ─ authorization_code grant ──────────────────────────────────────────
393 if (grantType === 'authorization_code') {
394 const { code, code_verifier, redirect_uri } = body;
395
396 if (!code || !code_verifier || !redirect_uri) {
397 return res.status(400).json({
398 error: 'invalid_request',
399 error_description: 'code, code_verifier, and redirect_uri are required',
400 });
401 }
402
403 let pending;
404 try {
405 pending = await consumePendingCode(String(code));
406 } catch (_) {
407 return res.status(503).json({
408 error: 'server_error',
409 error_description: 'Authorization service temporarily unavailable',
410 });
411 }
412
413 if (!pending) {
414 return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code invalid or expired' });
415 }
416 if (pending.clientId !== String(clientId)) {
417 return res.status(400).json({ error: 'invalid_grant', error_description: 'Client mismatch' });
418 }
419
420 // C5: redirect_uri MUST equal the one bound at authorization (RFC 6749 §4.1.3).
421 if (String(redirect_uri) !== pending.redirectUri) {
422 return res.status(400).json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' });
423 }
424
425 // PKCE S256: sha256(code_verifier) must equal the stored challenge.
426 if (_sha256Base64url(String(code_verifier)) !== pending.codeChallenge) {
427 return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
428 }
429
430 // Authorization must have been completed (userId bound by IDP callback).
431 if (!pending.userId) {
432 return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization not completed' });
433 }
434
435 const sub = pending.userId;
436
437 // C6: scope ceiling — intersection of requested and grantedScopes(sub).
438 // grantedScopes is scopesForRole(roleForSub(sub)); unknown role → member ceiling.
439 const ceiling = opts.grantedScopes(sub);
440 const effectiveScopes = applyScopeCeiling(pending.scopes, ceiling);
441
442 // C1: mint the web-session JWT (issueToken shape: {sub, provider, id, name, role}).
443 let accessToken;
444 try {
445 accessToken = await opts.issueAccessToken(sub);
446 } catch (_) {
447 return res.status(500).json({ error: 'server_error', error_description: 'Token issuance failed' });
448 }
449
450 // C2/C4: issue refresh token via durable gateway store (refresh-token-core backing).
451 let refreshResult;
452 try {
453 refreshResult = await opts.refreshStore.issue(sub, {
454 tokenTtlMs: NATIVE_REFRESH_TOKEN_TTL_MS,
455 meta: { ua: String(req.headers['user-agent'] || '').slice(0, 256) },
456 });
457 } catch (_) {
458 return res.status(503).json({ error: 'server_error', error_description: 'Refresh token issuance failed' });
459 }
460
461 return res.status(200).json({
462 access_token: accessToken,
463 token_type: 'Bearer',
464 expires_in: NATIVE_TOKEN_EXPIRY_SECONDS,
465 refresh_token: refreshResult.token,
466 scope: effectiveScopes.join(' '),
467 });
468 }
469
470 // ─ refresh_token grant (C2) ──────────────────────────────────────────
471 if (grantType === 'refresh_token') {
472 const presentedToken = body.refresh_token;
473 if (!presentedToken) {
474 return res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
475 }
476
477 let result;
478 try {
479 result = await opts.refreshStore.rotate(String(presentedToken), {
480 meta: { ua: String(req.headers['user-agent'] || '').slice(0, 256) },
481 });
482 } catch (_) {
483 // A transient store fault: do NOT treat as theft. Fail soft with 503.
484 return res.status(503).json({
485 error: 'server_error',
486 error_description: 'Session service temporarily unavailable',
487 code: 'SESSION_STORE_UNAVAILABLE',
488 });
489 }
490
491 if (!result.ok) {
492 const err = _nativeRefreshError(result.reason);
493 // C2: reason codes aligned to auth-session.mjs.
494 return res.status(401).json({ error: 'invalid_grant', error_description: err.message, code: err.code });
495 }
496
497 const sub = result.sub;
498 // C6: re-derive ceiling on every refresh (role may have changed since last login).
499 const ceiling = opts.grantedScopes(sub);
500
501 let accessToken;
502 try {
503 accessToken = await opts.issueAccessToken(sub);
504 } catch (_) {
505 return res.status(500).json({ error: 'server_error', error_description: 'Token issuance failed' });
506 }
507
508 // C2: new refresh token in the response body (not a cookie).
509 return res.status(200).json({
510 access_token: accessToken,
511 token_type: 'Bearer',
512 expires_in: NATIVE_TOKEN_EXPIRY_SECONDS,
513 refresh_token: result.token,
514 scope: ceiling.join(' '),
515 });
516 }
517
518 return res.status(400).json({
519 error: 'unsupported_grant_type',
520 error_description: 'grant_type must be authorization_code or refresh_token',
521 });
522 }
523 );
524
525 // ── Revocation (RFC 7009) ──────────────────────────────────────────────────
526
527 router.post(
528 '/revoke',
529 express.urlencoded({ extended: false }),
530 express.json({ limit: '16kb' }),
531 async (req, res) => {
532 res.set('Cache-Control', 'no-store');
533 const body = req.body || {};
534 const token = body.token;
535 if (token) {
536 try {
537 await opts.refreshStore.revoke(String(token));
538 } catch (_) {
539 // RFC 7009 §2.2: revocation always returns 200 regardless of errors.
540 }
541 }
542 return res.status(200).json({ ok: true });
543 }
544 );
545
546 // ─── completeNativeAuthorization ─────────────────────────────────────────
547
548 /**
549 * Complete the native authorization after the IDP (Google/GitHub) OAuth callback.
550 * Called from server.mjs when the round-trip state has the `native:` prefix.
551 *
552 * C3: Emits `iss` = issuerUrl on the loopback redirect (RFC 9207 §2).
553 * Value is identical to the `issuer` field in the discovery metadata, with no
554 * trailing-slash drift.
555 *
556 * C5: The redirect target is the `redirectUri` stored at authorization time (not
557 * taken from the current request), so a forged/mismatched redirect cannot be
558 * injected at this step.
559 *
560 * @param {string} nativeStateBase64 - base64url-encoded JSON from the `native:` state
561 * @param {string} userId - authenticated user sub ("provider:id") from passport
562 * @param {import('express').Response} res
563 * @returns {Promise<void>}
564 */
565 async function completeNativeAuthorization(nativeStateBase64, userId, res) {
566 let nativeState;
567 try {
568 nativeState = JSON.parse(Buffer.from(String(nativeStateBase64), 'base64url').toString('utf8'));
569 } catch (_) {
570 return res.status(400).json({ error: 'invalid_request', error_description: 'Malformed native state' });
571 }
572
573 const { code, redirectUri } = nativeState;
574 if (!code || !redirectUri) {
575 return res.status(400).json({ error: 'invalid_request', error_description: 'Malformed native state: missing code or redirectUri' });
576 }
577
578 // Bind the authenticated user to the pending code in the durable store (C4).
579 let bound;
580 try {
581 bound = await bindUserToCode(String(code), String(userId));
582 } catch (_) {
583 return res.status(503).json({ error: 'server_error', error_description: 'Authorization service temporarily unavailable' });
584 }
585
586 if (!bound) {
587 return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code invalid or expired' });
588 }
589
590 // Build the loopback redirect. The target is the stored redirectUri, not the
591 // current request URI, so the companion's listener receives the code at its port.
592 const redirectUrl = new URL(String(redirectUri));
593 redirectUrl.searchParams.set('code', String(code));
594 if (nativeState.state) redirectUrl.searchParams.set('state', String(nativeState.state));
595 // C3: iss parameter — equal to the issuerUrl advertised in discovery (RFC 9207 §2).
596 redirectUrl.searchParams.set('iss', issuerUrl);
597
598 return res.redirect(redirectUrl.toString());
599 }
600
601 return { router, completeNativeAuthorization };
602 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago