@odla-ai/cli 0.11.2 → 0.12.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.
@@ -32,34 +32,46 @@ Worker/admin side: `init({ appId: tenantId, adminToken: env.ODLA_API_KEY, endpoi
32
32
  The publishable key is public; `provision` stores it (`setAuth`). Rules evaluate
33
33
  the signed-in user's JWT as `auth` — e.g. `auth.signedIn`, `auth.email`.
34
34
 
35
- ## @odla-ai/calendar — read-only Google booking mirror
35
+ ## @odla-ai/calendar — live Google Calendar booking
36
36
 
37
37
  Calendar requires `services: ["db", "calendar"]` plus a `calendar.google`
38
- block. Normal provision opens a second, state-bound Google checkpoint issued by the platform for the
39
- human to grant `calendar.events.readonly`; Google tokens stay in the hosted
40
- connector and never become app/CLI secrets.
38
+ block (`availabilityCalendars` per env, optional `bookingCalendar`). Normal
39
+ provision opens a second, state-bound Google checkpoint issued by the
40
+ platform; the human grants the booking scopes in a browser. Google tokens
41
+ stay platform-side and never become app/CLI secrets. Google Calendar is the
42
+ single source of truth — odla stores no calendar or attendee data, and
43
+ nothing syncs.
41
44
 
42
45
  ```ts
43
46
  // Trusted Worker only — the admin key must never enter browser code.
44
- const calendar = initCalendar({
45
- appId: env.ODLA_TENANT,
47
+ const cal = initCalendar({
48
+ appId: env.ODLA_APP_ID, // registry app id
49
+ env: env.ODLA_ENV,
46
50
  adminToken: env.ODLA_API_KEY,
47
- endpoint: env.ODLA_ENDPOINT,
51
+ endpoint: env.ODLA_PLATFORM ?? "https://odla.ai",
48
52
  });
49
- const next = await calendar.bookings.next("member@example.com");
53
+ const fb = await cal.availability.freeBusy({ timeMin, timeMax });
54
+ const slots = computeBookableSlots(fb.busy, { from: timeMin, to: timeMax,
55
+ timezone, slotMinutes: 30 });
56
+ const { booking } = await cal.actions.create(
57
+ { summary, startAt, endAt, attendees: [email], timezone, meet: true },
58
+ { idempotencyKey: `booking:${recordId}` }, // retry-safe; Google emails the invite
59
+ );
50
60
  ```
51
61
 
52
- Browser code may query `$bookings` with `@odla-ai/db/client` only under an
53
- explicit view rule, and may import pure formatting helpers from
54
- `@odla-ai/calendar/client`. Do not import `initCalendar` or expose
55
- `ODLA_API_KEY` in a browser. Provider actions are not part of the read-only
56
- slice; a `calendar_not_connected`/501 action response is not a reason to widen
57
- credentials or request a Google token.
62
+ Store the returned `eventId`/`meetUrl` on the app's own record;
63
+ `actions.reschedule(eventId, …)` and `actions.cancel(eventId)` use it. A
64
+ taken slot rejects with non-retryable `calendar_slot_unavailable` refetch
65
+ slots, don't retry. Browser code never calls `initCalendar` or the platform
66
+ calendar routes; it hits the app's own endpoints and may import pure helpers
67
+ (`computeBookableSlots`, formatters) from `@odla-ai/calendar/client`.
68
+ `SlotPicker` from `@odla-ai/ui` renders the slot output directly.
58
69
 
59
70
  After consent, use `odla-ai calendar calendars --env dev --json` to discover
60
- selectable ids, edit the checked-in list, and optionally set a public per-env
61
- `bookingPageUrl` for Appointment Schedule embed/link mode. Re-provision, then verify with
62
- `calendar status --json` and `smoke`.
71
+ selectable ids and edit the checked-in list. Re-provision, then verify
72
+ `calendar status --env dev` reports `bookable: yes` and `smoke` passes. A
73
+ pre-pivot read-only grant reports `degraded`/`calendar_reconsent_required`;
74
+ re-run `calendar connect`.
63
75
 
64
76
  ## @odla-ai/ai — inference (Claude / GPT / Gemini)
65
77
 
@@ -2,7 +2,7 @@
2
2
  name: odla-migrate
3
3
  description: >
4
4
  Migrate a static site (e.g. GitHub Pages) to odla on Cloudflare in safe
5
- phases, then add a database, optional read-only calendar mirror, Clerk login, and AI. Use when the user wants to
5
+ phases, then add a database, optional Google Calendar booking, Clerk login, and AI. Use when the user wants to
6
6
  move a static or GitHub Pages site to Cloudflare/odla, or to add a backend,
7
7
  database, login/auth, or AI features to a static site via odla.
8
8
  runbookOrder:
@@ -49,10 +49,12 @@ installed skill at `../odla/SKILL.md` instead.
49
49
  approved o11y rotation with `--push-secrets` so the Worker is updated in the
50
50
  same run. If the final Wrangler transfer fails, use the CLI's printed
51
51
  non-rotating `secrets push` retry; never rotate again just to retry delivery.
52
- 7. Existing calendar embeds are public links, not proof of a booking. Preserve
53
- them via `bookingPageUrl`; never claim reconciliation until the read-only
54
- mirror is connected and the app correlates `$bookings` deliberately. Google
55
- OAuth tokens never enter the repo, CLI, or chat.
52
+ 7. Calendar booking is server-side only. `initCalendar` and the platform's
53
+ calendar routes authenticate with the app's full `ODLA_API_KEY` — never
54
+ call them from a browser; the browser talks to the app's own Worker
55
+ endpoints. An old public embed link is not a booking flow; preserve it via
56
+ `bookingPageUrl` only until the native flow is verified. Google OAuth
57
+ tokens never enter the repo, CLI, or chat.
56
58
 
57
59
  ## Phase state machine
58
60
 
@@ -108,7 +110,7 @@ Read the current phase's file when you enter it — not before:
108
110
  - references/phase-0-preflight.md
109
111
  - references/phase-1-static.md
110
112
  - references/phase-2-db.md
111
- - references/phase-2b-calendar.md (optional: preserve an embed and add authoritative read-only reconciliation)
113
+ - references/phase-2b-calendar.md (optional: live Google Calendar booking slots, create/reschedule/cancel through the app's own Worker)
112
114
  - references/phase-3-auth.md
113
115
  - references/phase-3b-user-sync.md (optional: mirror Clerk users into $users)
114
116
  - references/phase-4-ai.md
@@ -1,42 +1,130 @@
1
- # Phase 2b — Google Calendar (optional, read-only)
2
-
3
- Use this phase only when the existing site has a Google Appointment Schedule
4
- link/embed or locally reported meeting state. Keep the old booking experience
5
- working while adding an authoritative read-only mirror.
6
-
7
- Human obligations: review attendee-data retention and complete the second,
8
- server-issued Google consent page. They never paste an OAuth code or token.
9
-
10
- 1. Inventory the old behavior. Distinguish a public appointment-page link from
11
- actual event create/reschedule/cancel APIs. Do not promise more than exists.
12
- 2. Require `npm view @odla-ai/calendar@0.1.1 version` to succeed, then install
13
- `npm i --save-exact @odla-ai/calendar@0.1.1` as a runtime dependency. An
14
- exact-version `E404` blocks this phase; do not substitute a git checkout or
15
- another version. Add `"calendar"` beside
16
- `"db"` in services and configure:
17
- - `calendar.google.calendars.dev` (start with `"primary"`);
18
- - optional summary/organizer/attendee match filters;
19
- - deliberate `attendeePolicy: "full" | "hashed"`;
20
- - the old public Appointment Schedule URL as `bookingPageUrl.dev` when
21
- preserving the embed/link. It is public configuration, not a secret.
22
- 3. Run `doctor`, then `provision --dry-run`. Show the human the calendars,
23
- attendee policy, public booking page, and read-only scope.
24
- 4. Run normal dev provision. After odla device approval, pause again while the
25
- human grants Google `calendar.events.readonly`; the CLI follows initial sync.
26
- 5. Run `calendar calendars --env dev --json`, refine the checked-in ids, and
27
- re-provision. Verify `calendar status --env dev --json` and `smoke --env dev`.
28
- 6. Put `initCalendar` only in trusted Worker code. Browser code may keep using
29
- the public `bookingPageUrl`, query `$bookings` through explicit db rules, or
30
- import pure helpers from `@odla-ai/calendar/client`. Never expose
31
- `ODLA_API_KEY`.
32
- 7. Replace self-reported meeting state only after an explicit, tested
33
- application-to-booking correlation exists. A matching attendee email may be
34
- sufficient for some apps; do not silently regress later workflow statuses.
35
-
36
- This slice cannot create, reschedule, or cancel Google events. A static picker
37
- continues to be Google's sealed UI; odla supplies configuration, mirror reads,
38
- and reconciliation.
39
-
40
- Gate: the old embed/link still works, status is healthy, the selected calendars
41
- match config, `$bookings` smoke succeeds, no Google credential exists in local
42
- files, and `MIGRATION.md` records the disclosure/correlation decision.
1
+ # Phase 2b — Google Calendar booking (optional)
2
+
3
+ Use this phase when the site should offer real scheduling: visitors pick a
4
+ bookable slot and the app creates a live Google Calendar event — with a Meet
5
+ link and an invitation email that Google itself sends. Google Calendar is the
6
+ single source of truth; odla stores no calendar or attendee data. There is no
7
+ sync, no mirror, and no `$bookings` namespace.
8
+
9
+ Human obligations: complete the server-issued Google consent page in a
10
+ browser (they never paste an OAuth code or token), and receive the test
11
+ invitation email during verification.
12
+
13
+ Boundary that shapes every step: `initCalendar` and the platform's calendar
14
+ routes are **server-side only** they authenticate with the app's full
15
+ `ODLA_API_KEY`. Never call them from a browser; the browser talks only to
16
+ this app's own Worker endpoints and may import pure helpers from
17
+ `@odla-ai/calendar/client`.
18
+
19
+ 1. Require `npm view @odla-ai/calendar@0.2.0 version` to succeed, then
20
+ install `npm i --save-exact @odla-ai/calendar@0.2.0` as a runtime
21
+ dependency. An exact-version `E404` blocks this phase; do not substitute a
22
+ git checkout or another version.
23
+ 2. Add `"calendar"` beside `"db"` in services and author the config block —
24
+ booking rides the existing db key, so calendar adds no new secret:
25
+
26
+ ```js
27
+ calendar: {
28
+ google: {
29
+ availabilityCalendars: { dev: ["primary"] }, // 1–10 per env
30
+ bookingCalendar: { dev: "primary" }, // optional; defaults to first availability calendar
31
+ bookingPageUrl: { dev: null }, // optional legacy fallback link
32
+ },
33
+ },
34
+ ```
35
+
36
+ 3. Run `doctor`, then `provision --dry-run`; show the human the calendars and
37
+ the booking scopes. Run normal dev provision. Consent runs last: the CLI
38
+ prints/opens a state-bound Google URL and the human grants access in a
39
+ browser. If the platform connector reports a readiness 503
40
+ (`calendar_*_not_configured`), provision still completes and prints a
41
+ `calendar connect --env dev` resume hint that gap is platform-operator
42
+ work, not yours. Then `calendar calendars --env dev --json` to refine
43
+ checked-in ids, and verify `calendar status --env dev` shows
44
+ `bookable: yes` (a pre-pivot read-only grant shows `degraded` /
45
+ `calendar_reconsent_required`; re-run `calendar connect`).
46
+ 4. Build the **slots endpoint** in the app's Worker — capability-gated like
47
+ every other route (a booking endpoint that ignores Phase 3 auth decisions
48
+ is a regression):
49
+
50
+ ```ts
51
+ import { initCalendar, computeBookableSlots } from "@odla-ai/calendar";
52
+
53
+ // GET /api/booking/slots
54
+ const cal = initCalendar({
55
+ appId: env.ODLA_APP_ID,
56
+ env: env.ODLA_ENV,
57
+ adminToken: env.ODLA_API_KEY,
58
+ endpoint: env.ODLA_PLATFORM ?? "https://odla.ai",
59
+ });
60
+ const fb = await cal.availability.freeBusy({
61
+ timeMin: Date.now(),
62
+ timeMax: Date.now() + 14 * 86_400_000,
63
+ });
64
+ const slots = computeBookableSlots(fb.busy, {
65
+ from: fb.timeMin,
66
+ to: fb.timeMax,
67
+ timezone: BOOKING_TIMEZONE, // app-owned config, not user input
68
+ slotMinutes: 30,
69
+ businessHours: { days: [1, 2, 3, 4, 5], startHour: 9, endHour: 17 },
70
+ minNoticeMs: 24 * 3_600_000,
71
+ });
72
+ return Response.json({ slots, timezone: BOOKING_TIMEZONE });
73
+ ```
74
+
75
+ Business hours, slot length, notice, and buffers are this app's product
76
+ decisions — keep them in app config, and reject slot requests wider than
77
+ the window you offer.
78
+ 5. Build the **create endpoint** (`POST /api/booking`). Validate that the
79
+ requested slot is one this app offers (recompute from the same config),
80
+ then create — the platform re-validates availability under its booking
81
+ lease, so a stale slot cannot double-book:
82
+
83
+ ```ts
84
+ const { booking, duplicate } = await cal.actions.create(
85
+ {
86
+ summary: `Intro call — ${name}`,
87
+ startAt, endAt,
88
+ attendees: [email],
89
+ timezone: BOOKING_TIMEZONE,
90
+ meet: true, // Meet link on the event and in the invite
91
+ // notify defaults true → Google emails the invitation
92
+ },
93
+ { idempotencyKey: `booking:${applicationId}` }, // your own stable record id
94
+ );
95
+ ```
96
+
97
+ Store the returned `booking.eventId` and `booking.meetUrl` on the app's
98
+ own record (its odla-db row) — that record is the app's only persistence;
99
+ attendee data lives in Google. On `calendar_slot_unavailable` (409,
100
+ non-retryable) return a "slot taken" response; the client refetches
101
+ `/api/booking/slots` and re-picks. On retryable
102
+ `calendar_booking_in_progress`, retry once after a short delay.
103
+ 6. Reschedule/cancel go through the stored id:
104
+ `cal.actions.reschedule(eventId, { startAt, endAt })` and
105
+ `cal.actions.cancel(eventId)` (idempotent) — expose them as app endpoints
106
+ gated to the record's owner. Attendees can also act through Google's own
107
+ invitation flows; treat Google as authoritative.
108
+ 7. Frontend. Two supported shapes:
109
+ - **`@odla-ai/ui` SlotPicker** — renders the slots response directly
110
+ (`{ slots, timezone, onSelect }`). On a non-framework static site use a
111
+ Preact island: a small entry that mounts `<SlotPicker>` into a div,
112
+ bundled with esbuild as an IIFE with `react`/`react-dom` aliased to
113
+ `preact/compat`, committed as a static asset next to the site's other
114
+ assets.
115
+ - **Plain fetch + buttons** — `fetch("/api/booking/slots")`, render times
116
+ with `formatBookingRange` from `@odla-ai/calendar/client`, POST the
117
+ chosen slot.
118
+ Either way the browser only ever talks to this app's Worker.
119
+ 8. If the old site had a Google Appointment Schedule embed/link, keep it
120
+ working via `bookingPageUrl` until the native flow is verified, then
121
+ retire it deliberately.
122
+
123
+ Gate (live dev round-trip, all of it): a booking made through the site's own
124
+ UI creates the event on the booking calendar with a Meet link; the Google
125
+ invitation email arrives in the test attendee's inbox; a repeat POST with the
126
+ same idempotency key returns `duplicate: true` (no second event); a taken
127
+ slot returns 409 and the UI refetches slots; reschedule and cancel work via
128
+ the stored `eventId`; zero attendee data is persisted anywhere except the
129
+ app's own tenant record; no Google credential exists in local files; and
130
+ `MIGRATION.md` records the booking-flow decision.
@@ -15,6 +15,9 @@
15
15
  | `o11y_…` (o11y ingest token) | wrangler secret `ODLA_O11Y_TOKEN` + `.odla/credentials.local.json` (0600) + `.dev.vars` | only if the app enables o11y; provision issues/reuses it and moves it alongside the db key; never a var, never chat |
16
16
  | `ODLA_ENDPOINT` / `ODLA_TENANT` / `ODLA_PLATFORM` / `ODLA_APP_ID` / `ODLA_ENV` | wrangler `vars` | not secrets; keep them set in every env block |
17
17
  | `ODLA_O11Y_ENDPOINT` / `ODLA_O11Y_SERVICE` | `.dev.vars` from provision or optional wrangler `vars` overrides | not secrets; public ingest defaults to `https://o11y.odla.ai` and `ODLA_APP_ID` when the token is present |
18
+ | `GOOGLE_CALENDAR_CLIENT_ID` / `GOOGLE_CALENDAR_CLIENT_SECRET` / `CALENDAR_TOKEN_MASTER_KEY` | platform-operator Worker secrets on `odla-apps` | never part of a customer migration; never in chat, the repo, or the app's wrangler config — a readiness 503 (`calendar_*_not_configured`) means the operator sets these, not you |
19
+ | Google calendar grant (OAuth tokens) | platform-side, sealed after human browser consent | the human clicks the server-issued consent URL; no code or token is ever pasted, and the CLI/app/repo never hold one |
20
+ | calendar booking auth (app side) | none — rides the existing `ODLA_API_KEY` | enabling calendar adds NO new app secret; `initCalendar` uses the app's server db key, server-side only, never in a browser |
18
21
 
19
22
  System AI provider credentials are platform-owned and never enter a customer
20
23
  migration. `odla-ai security run` receives only app/run-bound role grants; do
@@ -29,11 +29,22 @@ prints what it verified against.
29
29
 
30
30
  Cause: the `odla_dev_…` token expired (~24h) or the handshake was never
31
31
  approved.
32
- Fix: re-run `npx @odla-ai/cli provision` — it starts a fresh handshake; the
33
- matching existing account must be supplied with `--email` or
34
- `ODLA_USER_EMAIL`, then signs in, reviews, and approves the exact code at the
35
- printed URL. Never ask for a password or session token. Use `--no-open` in
36
- non-interactive shells if the browser launch misbehaves.
32
+ Fix: re-run `npx @odla-ai/cli provision` — it resumes a still-pending
33
+ handshake if one exists, else starts a fresh one; the matching existing
34
+ account must be supplied with `--email` or `ODLA_USER_EMAIL`, then signs in,
35
+ reviews, and approves the exact code at the printed URL. Never ask for a
36
+ password or session token.
37
+
38
+ ## Handshake exits with code 75 / "handshake still pending"
39
+
40
+ Cause: nobody approved within the wait cap (90s outside an interactive
41
+ terminal; `--wait <seconds>` overrides). This is normal in agent shells and
42
+ loses nothing — the handshake is persisted in `.odla/handshake.local.json`.
43
+ Fix: relay the printed approval URL to the human **verbatim** (browser
44
+ launch is best-effort — a sandboxed shell can report success with no tab
45
+ appearing), wait for them to approve, then re-run the same command; it
46
+ resumes the same code and collects the token. Do not loop unattended
47
+ retries; each handshake lives ~10 minutes.
37
48
 
38
49
  ## `smoke` fails: missing credentials
39
50