@glance-apps/intents
v1.4.0
Published
Shared intent protocol for the GLANCE family of apps
Downloads
645
Readme
@glance-apps/intents
Shared intent protocol for the GLANCE family of apps. Implements the protocol specified in dayglance-intent-protocol.md, consumed by dayGLANCE, lastGLANCE, and lifeGLANCE.
The package provides Zod schemas, normalizers, idempotency helpers, and WebDAV envelope helpers — the building blocks. HTTP clients, polling loops, GC cadence, and UI feedback stay app-side.
Installation
npm install @glance-apps/intents
# or: pnpm add @glance-apps/intents
# or: yarn add @glance-apps/intentsRequires Node 20+ (uses Web Crypto via globalThis.crypto). Browser-compatible.
Usage
Validate an inbound intent
When a consumer reads an event file off WebDAV (or receives an Android broadcast, or parses a URL), it passes the raw JSON through parseEnvelope:
import { parseEnvelope } from '@glance-apps/intents';
const raw = JSON.parse(await fetchEventFile(filename));
const envelope = parseEnvelope(raw);
// envelope is now a typed, discriminated-union Envelope.
switch (envelope.action) {
case 'create':
// envelope.payload is CreatePayload
break;
case 'notify':
// envelope.payload is NotifyPayload
break;
// ...
}parseEnvelope throws on validation failure — malformed envelopes are exceptional, not silently filterable.
Build an outbound intent
When emitting an event (e.g. dayGLANCE emitting a notify after a task completion), construct the envelope with buildEnvelope and write it to WebDAV using your existing client:
import { ACTIONS, EVENTS, buildEnvelope, filenameFor } from '@glance-apps/intents';
const envelope = buildEnvelope({
action: ACTIONS.NOTIFY,
emittedBy: 'app.dayglance',
payload: {
event_id: 'evt_42',
source_app: 'app.lastglance',
source_entity_id: 'chore_42',
event: EVENTS.COMPLETED,
task_id: 'tsk_8a91',
title: 'Replace HVAC filter',
timestamp: '2026-05-17T14:30:22Z',
completed_at: '2026-05-17T14:30:22Z',
},
});
await webdavClient.put(`/GLANCE/events/${filenameFor(envelope)}`, JSON.stringify(envelope));buildEnvelope is generic over action so the payload is type-checked against the matching schema — passing a create action with a notify-shaped payload is a compile error.
emitted_at defaults to new Date() and event_id defaults to a generated eventId() (with timestamp aligned to emitted_at) — pass them explicitly only for testability or replay.
Encrypt an outbound intent (optional)
Intents encryption uses a two-step key model. At setup time (once per app, when the user enables the intents encryption toggle), derive and cache an intents root key. At emit time, pass a deriveKey callback that runs HKDF from the cached root key — no passphrase needed after setup.
Setup (once per app):
import { deriveIntentsRootKey } from '@glance-apps/intents';
// sharedRootSalt is a 16-byte Uint8Array read from (or written to) a fixed
// file on the shared WebDAV endpoint — app-owned logic, not in the package.
const sharedRootSalt = await fetchOrWriteSharedSalt(webdavClient);
const rootKey = await deriveIntentsRootKey(passphrase, sharedRootSalt);
// Cache rootKey non-extractably in IndexedDB (app-owned); discard passphrase.
// After this point the passphrase is never needed by intents again.Emit:
import { ACTIONS, EVENTS, buildEncryptedEnvelope, deriveEnvelopeKey, filenameFor } from '@glance-apps/intents';
// cachedRootKey comes from IndexedDB — no passphrase needed.
const deriveKey = (salt: Uint8Array<ArrayBuffer>) => deriveEnvelopeKey(cachedRootKey, salt);
const encrypted = await buildEncryptedEnvelope(
{
action: ACTIONS.NOTIFY,
emittedBy: 'app.dayglance',
payload: {
event_id: 'evt_42',
source_app: 'app.lastglance',
source_entity_id: 'chore_42',
event: EVENTS.COMPLETED,
task_id: 'tsk_8a91',
title: 'Replace HVAC filter',
timestamp: '2026-05-17T14:30:22Z',
},
},
deriveKey,
);
await webdavClient.put(`/GLANCE/events/${filenameFor(encrypted)}`, JSON.stringify(encrypted));The package generates a fresh random 16-byte salt per envelope, calls deriveKey(salt) to obtain an AES-256-GCM key, and embeds the salt in the envelope header. Any app that derived the same root key can reconstruct the matching per-envelope key on decrypt. Only create and notify actions are encryptable; query, open, and complete always use the plaintext path.
Read an event file that may be encrypted
When polling WebDAV, check whether each file is encrypted before deciding which parser to call. Use the typed errors to handle failures gracefully:
import {
WrongKeyError,
MalformedEnvelopeError,
parseEnvelope,
parseEncryptedEnvelope,
deriveEnvelopeKey,
} from '@glance-apps/intents';
const raw = JSON.parse(await fetchEventFile(filename));
if ((raw as { encrypted?: unknown }).encrypted === true) {
// cachedRootKey comes from IndexedDB — null if intents encryption was never set up.
if (!cachedRootKey) {
logWarning(filename, 'skipped: intents encryption not set up in this app');
return;
}
try {
const deriveKey = (salt: Uint8Array<ArrayBuffer>) => deriveEnvelopeKey(cachedRootKey, salt);
const envelope = await parseEncryptedEnvelope(raw, deriveKey);
handleEnvelope(envelope);
} catch (e) {
if (e instanceof WrongKeyError) logWarning(filename, 'decryption failed: wrong key — verify same passphrase used in both apps at setup');
else if (e instanceof MalformedEnvelopeError) logWarning(filename, `malformed: ${e.message}`);
else throw e;
}
} else {
const envelope = parseEnvelope(raw);
handleEnvelope(envelope);
}Plaintext and encrypted envelopes coexist in the same WebDAV directory; the consumer handles both.
Normalize user input before validation
Schemas validate shape; normalizers turn flexible inputs into canonical forms. The handler typically composes them:
import { normalizeDue, normalizePriority, normalizeTags } from '@glance-apps/intents';
normalizePriority('HIGH'); // → 3
normalizePriority(2); // → 2
normalizeDue('tomorrow'); // → { due: '2026-05-18', all_day: true }
normalizeDue('2026-05-20T14:00:00Z'); // → { due: '2026-05-20T14:00:00Z', all_day: false }
normalizeTags({ title: 'fix sink #home #urgent', tags: 'errands' });
// → { title: 'fix sink', tags: ['home', 'urgent', 'errands'] }normalizeRecurring expands the shorthand strings (daily, weekdays, weekly, monthly, yearly) to canonical RRULEs and passes full RRULE strings through unchanged.
Generate idempotency keys
At-least-once delivery is the norm across every transport. Two helpers cover the dedup concerns:
import { createKey, eventId } from '@glance-apps/intents';
// Emitter uses eventId() to stamp each outbound event.
const id = eventId(); // → '20260517T143022Z-7f3a9c'
// Receiver uses createKey() to recognize that an inbound create matches
// an existing task (same source_app + source_entity_id + due → same key).
const key = await createKey('app.lastglance', 'chore_42', '2026-05-20');
// → '<64 hex chars, SHA-256>'createKey is async (Web Crypto); eventId is sync. Inputs to createKey are expected to be already-normalized — the handler composes normalizeDue → createKey.
Public API
Organized by module. Every named export is intentional and governed by semver.
Constants
SCHEMA_VERSION, ACTIONS, EVENTS, ENTITY_TYPES, PRIORITY, PRIORITY_ALIASES, TABS, QUERY_RETURN_VARS, UNIVERSAL_RETURN_VARS, RETURN_VARS, RETURN_VAR_TYPES, ANDROID_ACTIONS, SOURCE_APPS, UPDATED_FIELDS. Each ships with a matching narrow-literal type (Action, Event, EntityType, PriorityLevel, Tab, QueryReturnVar, UniversalReturnVar, ReturnVar, AndroidAction, SourceApp, UpdatedField).
Schemas
Flat: CreateSchema, CompleteSchema, OpenSchema, QuerySchema, NotifySchema, EnvelopeSchema, EncryptedEnvelopeSchema, plus inferred types CreatePayload, CompletePayload, OpenPayload, QueryPayload, NotifyPayload, Envelope, EncryptedEnvelope.
CreateSchema optional fields: title (required), due, duration, all_day, deadline, tags, priority, notes, project, recurring, source_app, source_entity_id, assigned_user_ids (string[] — list of user IDs the task is assigned to, for cross-app multi-user filtering), entity_type (string — emitting app's classification of the intent, e.g. "goal" or "task").
NotifySchema optional fields include completed_at and completed_by_user_id (string — sync ID of the user who completed the task, for cross-app completion attribution).
Versioned: schemas.v1.* for the same surface, so consumers that explicitly pin a protocol version can do so without breaking when v2 lands alongside.
Normalizers
normalizePriority, normalizeRecurring, normalizeTags, normalizeDue. Types: NormalizeTagsInput, NormalizeTagsOutput, NormalizeDueOutput.
Idempotency
eventId(now?), createKey(source_app, source_entity_id, due).
Crypto helpers
encryptAesGcm(plaintext, key), decryptAesGcm(ciphertext, iv, key). Both async, operating on CryptoKey (Web Crypto API). Per-call random 12-byte IV; no IV reuse.
deriveIntentsRootKey(passphrase, sharedRootSalt): derives the intents root key from the cloud sync passphrase and a shared 16-byte salt stored on the WebDAV endpoint. Uses PBKDF2-SHA-256 at 310,000 iterations. Returns a non-extractable HKDF CryptoKey with usages: ['deriveKey'] — safe to cache in IndexedDB. Called once at intents-encryption setup.
deriveEnvelopeKey(rootKey, envelopeSalt): derives a non-extractable AES-256-GCM CryptoKey (usages: ['encrypt', 'decrypt']) from the cached intents root key and the per-envelope salt via HKDF-SHA-256. Use inside the deriveKey callback passed to buildEncryptedEnvelope / parseEncryptedEnvelope.
Error classes (all extend Error): NoKeyError, WrongKeyError, NotEncryptedError, MalformedEnvelopeError.
WebDAV envelope helpers
Plaintext: buildEnvelope, parseEnvelope, filenameFor, parseFilename. Types: ActionPayloadMap, BuildEnvelopeArgs, ParsedFilename.
Encrypted: buildEncryptedEnvelope(args, deriveKey), parseEncryptedEnvelope(raw, deriveKey). Both async. deriveKey has signature (salt: Uint8Array<ArrayBuffer>) => Promise<CryptoKey>; pass (salt) => deriveEnvelopeKey(cachedRootKey, salt) where cachedRootKey is the non-extractable HKDF key from deriveIntentsRootKey. A fresh random 16-byte salt is generated per envelope on build and embedded in the envelope header; the consumer extracts the salt and calls deriveKey to reconstruct the matching key. buildEncryptedEnvelope is generic over EncryptableAction ('create' | 'notify'). Types: EncryptableAction, BuildEncryptedEnvelopeArgs.
GLANCEvault row helpers
The building blocks for a database-backed intents transport, parallel to the WebDAV envelope helpers. Same boundary: this package encodes/decodes the intent_events row and parses the cursor token; the HTTP client, the receive-cursor storage, and the polling loop stay app-side (just like the WebDAV poller and its localStorage/settings cursor).
The wire format matches the GLANCEvault intents endpoints: fields are camelCase and the envelope rides as an opaque base64 string (the server never looks inside it). The codec's API still takes and returns a structured Envelope object — only the wire envelope is base64.
buildIntentRow(envelope, ttl): takes a plaintext or encrypted Envelope object and encodes one element of the events array a client POSTs to POST /intents/batch — { eventId, envelope, expiresAt }, where envelope is base64 of the envelope's UTF-8 JSON. eventId is lifted from the envelope's own event_id to the top level (the server reads the idempotency key from the row, not the opaque envelope, so re-POSTing is a no-op). ttl is { ttlMs } (expiry computed from the envelope's emitted_at, so a re-built row is byte-identical) or { expiresAt: Date }. The outbound row has no seq (server-assigned, inbound-only) and no accountId — accountId is a top-level field of the batch body, not the row — so a send can never carry or advance a receive cursor.
import { buildEnvelope, buildIntentRow } from '@glance-apps/intents';
const envelope = buildEnvelope({ action: 'notify', emittedBy: 'app.dayglance', payload: { /* ... */ } });
const row = buildIntentRow(envelope, { ttlMs: 7 * 24 * 60 * 60 * 1000 });
// App-owned I/O: wrap the row(s) with the account scope and POST the batch.
await fetch(`${vaultUrl}/intents/batch`, {
method: 'POST',
body: JSON.stringify({ accountId, events: [row] }),
});parseIntentRow(raw): validates a row from GET /intents/list — { eventId, envelope, seq, expiresAt, serverMtime } (camelCase, no account_id, envelope a base64 string) — and base64-decodes the envelope back into the structured object. The decoded envelope is returned opaque; route it to parseEnvelope or parseEncryptedEnvelope by its encrypted flag, exactly as the WebDAV read path does. seq is the value the receiver advances its cursor over.
isExpired(row, now?): pure check against expiresAt, to skip a row past its TTL but not yet pruned server-side.
parseSince(raw) / formatSince(cursor): parse and format the integer since cursor for the list request (GET /intents/list?...&since=<int>). null means "no cursor yet" and formats as 0 (lists the full backlog, since seq ≥ 1). Cursor persistence and advancement are app-owned; the package holds no cursor state.
Schemas (both validate the camelCase, base64-string wire shape): OutboundIntentRowSchema, IntentEventRowSchema. Types: IntentEnvelope, OutboundIntentRow, IntentEventRow, IntentRowTtl.
Versioning
The package version tracks the protocol's schema_version directly:
1.x.y→ protocol v12.0.0→ protocol v2 (breaking, coordinated multi-app upgrade)
Within v1, additive minor bumps are non-breaking; consumers can upgrade freely. When v2 ships, src/schemas/v2/* lives alongside src/schemas/v1/* so consumers can validate against both.
Full versioning policy (what counts as breaking, minor, patch) is documented in dayglance-intent-protocol.md under "Versioning."
Notes for consumers
NotifySchemavalidates shape only. Event-conditional rules from the spec —previous_duepresent whenevent === 'rescheduled',completed_at == timestampwhenevent === 'completed'— are enforced by the emitter, not by the schema. If you need to enforce them at receive time, do a second-pass check on your end.- Normalize before validating in some flows. The schema accepts ISO date strings as
due;normalizeDueacceptstoday/tomorrow/undefinedand produces ISO strings. Call the normalizer first, then validate. createKeyassumes normalized inputs. The handler is expected to callnormalizeDuebeforecreateKeyso relative inputs like"today"and the literal date they resolve to produce the same key.schemas.v1.*is the stable namespace for protocol v1. Use the namespaced form if you want to be explicit about which version you validate against; use the flat form for convenience.- Encryption is per-app, per-user, opt-in. Plaintext and encrypted envelopes coexist in the same WebDAV directory. Consumers without a key skip encrypted events; they don't hard-fail.
- Two-step key model. At intents-encryption setup, call
deriveIntentsRootKey(passphrase, sharedRootSalt)once and cache the result in IndexedDB. At emit/poll time, pass(salt) => deriveEnvelopeKey(cachedRootKey, salt)as thederiveKeycallback tobuildEncryptedEnvelope/parseEncryptedEnvelope— no passphrase needed after setup. ThesharedRootSaltlives on the shared WebDAV endpoint (app-owned I/O); both apps reading it from the same file derive the same root key and can decrypt each other's envelopes. Onlycreateandnotifyare encryptable; the other three actions always use the plaintext path. - Runtime dependency:
zod. Kept as a single external import; tsup does not bundle it. Consumers can use their own zod version (any^4).
License
MIT — see LICENSE.
