npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/intents

Requires 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 normalizeDuecreateKey.

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 accountIdaccountId 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 v1
  • 2.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

  • NotifySchema validates shape only. Event-conditional rules from the spec — previous_due present when event === 'rescheduled', completed_at == timestamp when event === '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; normalizeDue accepts today/tomorrow/undefined and produces ISO strings. Call the normalizer first, then validate.
  • createKey assumes normalized inputs. The handler is expected to call normalizeDue before createKey so 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 the deriveKey callback to buildEncryptedEnvelope / parseEncryptedEnvelope — no passphrase needed after setup. The sharedRootSalt lives 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. Only create and notify are 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.