@octabits-io/foundation
v0.13.0
Published
Core utilities: Result types, IoC container, logger, helpers, Zod config fragments, RBAC engine, OIDC/JWT validation, Vault secret loader, a provider-agnostic captcha contract, PII encryption, Drizzle ORM toolkit, and iCal ingestion
Downloads
1,807
Readme
@octabits-io/foundation
Shared foundation library providing core primitives used across the platform: error handling, dependency injection, structured logging, common utilities, Zod config fragments, an RBAC engine, OIDC/JWT validation, per-scope signing, a Vault secret loader, a captcha contract, PII encryption (./pii), Drizzle ORM utilities (./drizzle/*), and iCal ingestion (./ical).
Modules
@octabits-io/foundation/result
Type-safe error handling using the Result pattern — no thrown exceptions.
import type { Result, OctError } from '@octabits-io/foundation/result';
import { ok, err, tryCatch, tryCatchAsync, isOctError, toOctError } from '@octabits-io/foundation/result';
// Result<T, E> is { ok: true; value: T } | { ok: false; error: E }
function divide(a: number, b: number): Result<number> {
if (b === 0) return err({ key: 'division_by_zero', message: 'Cannot divide by zero' });
return ok(a / b);
}
// Wrap throwing code into a Result
const result = tryCatch(() => JSON.parse(input));
const asyncResult = await tryCatchAsync(() => fetch(url));
if (result.ok) {
console.log(result.value);
} else {
console.log(result.error.message); // OctExceptionError with key 'exception'
}API error types — discriminated union for HTTP error responses:
ValidationApiError, NotFoundApiError, BadRequestApiError, UnauthorizedApiError, ForbiddenApiError, InternalApiError, ApiErrorResponse
@octabits-io/foundation/ioc
Lightweight IoC container with singleton, scoped, and transient lifetimes.
import { IoC, ServiceLifetime } from '@octabits-io/foundation/ioc';
type Services = {
db: Database;
logger: Logger;
userRepo: UserRepo;
};
const container = new IoC<Services>();
container.register('db', () => new Database(), ServiceLifetime.Singleton);
container.register('logger', () => new Logger(), ServiceLifetime.Singleton);
container.register('userRepo', (c) => new UserRepo(c.resolve('db')), ServiceLifetime.Scoped);
// Create a request-scoped child container
const scope = container.createScope();
const repo = scope.resolve('userRepo'); // new instance per scope
const db = scope.resolve('db'); // shared singleton
// Cleanup scoped resources
scope.onDispose(() => repo.close());
await scope.dispose();
// Proxy object for convenient access
const services = container.toServices();
services.db; // resolves on property accessKey types: ServiceResolver<T>, DisposableServiceResolver<T>, SystemScopeFactory<T>
@octabits-io/foundation/logger
Structured logging with OpenTelemetry-compatible output.
import { createLoggerService } from '@octabits-io/foundation/logger';
const loggerService = createLoggerService({
config: {
serviceName: 'my-api',
serviceVersion: '1.0.0',
environment: 'production', // JSON output; 'development' for human-readable
logLevel: 'info',
},
});
const logger = loggerService.logger;
logger.info('Server started', { port: 3000 });
logger.error('Request failed', error, { requestId: 'abc123' });
// Child loggers carry context
const reqLogger = logger.child({ requestId: 'abc123' });
reqLogger.info('Processing'); // includes requestId in all messages@octabits-io/foundation/utils
Common utility functions.
import { slugify, isUrlFriendly } from '@octabits-io/foundation/utils';
import { tryDecodeBase64 } from '@octabits-io/foundation/utils';
import { normalizeQueryParamToStringOrUndefined } from '@octabits-io/foundation/utils';
slugify('Héllo Wörld!'); // 'hello-world'
isUrlFriendly('my-slug_01'); // true
const decoded = tryDecodeBase64('aGVsbG8='); // Result<string>
// Normalize framework query params (string | string[] | null | undefined)
normalizeQueryParamToStringOrUndefined(['foo', 'bar']); // 'foo'
normalizeQueryParamToIntOrUndefined('42'); // 42
normalizeQueryParamToArrayOrUndefined('single'); // ['single']Also exported: createDateProvider / DateProvider (clock-injection seam),
createLruCacheService (bounded LRU cache), withRetry (backoff retries,
RetryConfig / RetryOptions), and URL_FRIENDLY_REGEX.
@octabits-io/foundation/config-schema
Reusable Zod config fragments — compose them into your app's config schema.
import {
nonEmptyString,
nonEmptyUrl,
DATABASE_CONFIG_SCHEMA,
createRlsSchema,
LOGGING_CONFIG_SCHEMA,
} from '@octabits-io/foundation/config-schema';
const CONFIG_SCHEMA = z.object({
database: DATABASE_CONFIG_SCHEMA,
rls: createRlsSchema(true), // default-enabled RLS toggle
logging: LOGGING_CONFIG_SCHEMA,
apiUrl: nonEmptyUrl(),
});@octabits-io/foundation/rbac
Self-contained, dependency-free RBAC engine: pure resource/action subset checking, generic over a caller-supplied permission statement. The concrete statement matrix and named roles live in the consuming application.
import { createRole, checkLocalPermission } from '@octabits-io/foundation/rbac';
const statement = {
article: ['read', 'write', 'delete'],
settings: ['read', 'write'],
} as const;
const editor = createRole<typeof statement>({
article: ['read', 'write'],
settings: ['read'],
});
editor.authorize({ article: ['write'] }); // { success: true }
checkLocalPermission(editor, { settings: ['write'] }); // false@octabits-io/foundation/auth
Generic OIDC/JWT validation (optional peer: jose). Lazily discovers the JWKS
URI from the issuer's OIDC discovery document, verifies signatures via
createRemoteJWKSet (cached, rotation-aware), and hands verified payloads to a
caller-supplied claimMapper that produces your domain token shape.
import { createJwtValidationService } from '@octabits-io/foundation/auth';
const jwtService = createJwtValidationService<MyToken>({
issuerUrl: 'https://auth.example.com',
audience: 'my-api',
logger,
claimMapper: (payload) => ({ ok: true, value: { userId: payload.sub! } }),
// optional E2E bypass (neutralized in production):
// authBypassSecret, bypassToken
});
const result = await jwtService.validateAuthorizationHeader(req.headers.authorization);
if (result.ok) console.log(result.value.userId);
jwtService.extractBearerToken('Bearer abc'); // 'abc'API key format — issue and verify <prefix><keyId>.<secret> bearer tokens.
Pure node:crypto, no I/O: keyId enables O(1) row lookup, only the secret's
SHA-256 hash is persisted, and verifyHash compares in constant time.
import { createApiKeyFormat } from '@octabits-io/foundation/auth';
const apiKeys = createApiKeyFormat({ prefix: 'acme_' });
const keyId = apiKeys.generateKeyId();
const secret = apiKeys.generateSecret();
const token = apiKeys.formatToken(keyId, secret); // 'acme_<keyId>.<secret>'
const storedHash = apiKeys.hashSecret(secret); // persist this + keyId
const publicPrefix = apiKeys.deriveKeyPrefix(keyId); // 'acme_<keyId>' — safe to show
// On an incoming request:
const parsed = apiKeys.parseToken(token); // { keyId, secret } | null
if (parsed) {
// look up the row by parsed.keyId, then:
apiKeys.verifyHash(parsed.secret, storedHash); // constant-time boolean
}Bearer dispatcher — one entrypoint for any Authorization: Bearer ...
header. Strategies are tried in order; the first whose matches returns true
owns the token. All strategies return the shared Result shape, so callers stay
agnostic to which one ran.
import { createBearerAuthService } from '@octabits-io/foundation/auth';
const bearer = createBearerAuthService<MyPrincipal>({
strategies: [
{ matches: (t) => apiKeys.isApiKeyToken(t), validate: (t) => validateApiKey(t) },
{ matches: () => true, validate: (t) => jwtService.validateToken(t) }, // fallback
],
});
const result = await bearer.validateAuthorizationHeader(req.headers.authorization);
// { ok: false, error: { key: 'missing_token' | 'no_matching_strategy' } } when unhandled@octabits-io/foundation/signing
Generic per-scope, per-purpose signing (optional peer: jose, loaded lazily and
only for the JWT primitives). One service for HMAC/JWT crypto, HKDF key
derivation, and constant-time comparison — so no consumer re-rolls its own. The
scopeKey is an opaque string feeding HKDF domain separation (not a DB column);
each purpose gets its own 256-bit key. Keys live behind an injected keyStore.
import { createScopedSigningService } from '@octabits-io/foundation/signing';
const signing = createScopedSigningService({
infoPrefix: 'acme', // → length-prefixed HKDF info `4:acme|5:reply|signing-key-v1`
scopeKey: tenantId, // opaque salt for domain separation
keyStore: { read, write }, // your `purpose → base64-key` persistence
masterSecret: process.env.SIGNING_MASTER_SECRET, // optional; enables derive + JWT signing
});
// Full-length detached HMAC (base64url)
const sig = await signing.hmac('reply', message); // Result<string>
await signing.verifyHmac('reply', message, sig.value); // Result<boolean> (constant-time)
// Length-constrained hex tag (default 12 bytes / 24 hex chars)
const tag = await signing.shortTag('reply', conversationId);
await signing.verifyShortTag('reply', conversationId, tag.value);
// Self-contained HS256 token (auto-provisions the key into keyStore)
const jwt = await signing.signJwt('booking', { bookingId }, { expiresAt });
await signing.verifyJwt('booking', jwt.value); // Result<JWTPayload>With a masterSecret, keys are HKDF-derived on the fly (no store round-trip, and
verifiable before any lookup). Without one, the service is read-only against
keyStore — verifying, and signing under, keys a provisioning path wrote
earlier; signing an unprovisioned purpose returns scoped_signing_key_not_found.
Errors are Result values (scoped_signing_key_not_found,
scoped_signing_signature_invalid), never thrown.
@octabits-io/foundation/vault
Boot-time HashiCorp Vault secret loader — hydrates process.env from KV-v2
paths declared in a JSON manifest, before config loads. Plain fetch, no SDK.
(Formerly the standalone @octabits-io/vault package.)
import { loadVaultSecrets, parseSecretManifest } from '@octabits-io/foundation/vault';
const manifest = parseSecretManifest(await readFile('secrets.json', 'utf8'));
await loadVaultSecrets(manifest); // populates process.env from Vault KV-v2@octabits-io/foundation/captcha
Provider-agnostic captcha contract (challenge → redeem → verified-token →
validate) with a no-op implementation for dev/test and the ALTCHA config schema.
The root entry is vendor-free; the ALTCHA proof-of-work implementation lives
behind @octabits-io/foundation/captcha/altcha so altcha-lib (an optional
peer) is only loaded when used. (Formerly the standalone @octabits-io/captcha
package.)
import { createNoopCaptchaService, CAPTCHA_CONFIG_SCHEMA } from '@octabits-io/foundation/captcha';
import { createAltchaCaptchaService } from '@octabits-io/foundation/captcha/altcha';@octabits-io/foundation/pii
Encryption toolkit for Personally Identifiable Information (PII). Uses age encryption (X25519 + ChaCha20-Poly1305) with a built-in TypeScript implementation. (Formerly the standalone @octabits-io/pii package.)
PII Encryption Service
High-level service for encrypting/decrypting PII fields. Inject once with keys, use everywhere.
import { createPiiEncryptionService, createPiiEncryptionOnlyService } from '@octabits-io/foundation/pii';
// Full encrypt + decrypt (backend services with access to the secret key)
const pii = createPiiEncryptionService({
recipient: 'age1...', // public key
identity: 'AGE-SECRET-KEY-1...', // private key
});
const encrypted = await pii.encryptString('[email protected]');
const decrypted = await pii.decryptString(encrypted.value);
// JSON values with Zod validation on decrypt
const encJson = await pii.encryptJson({ street: '123 Main St', city: 'Berlin' });
const decJson = await pii.decryptJson(encJson.value, AddressSchema);
// Encrypt-only variant (e.g., ingestion services that don't need to read PII)
const encryptOnly = createPiiEncryptionOnlyService({ recipient: 'age1...' });All methods return Result<T, PiiEncryptionError | PiiDecryptionError> and pass through null/undefined inputs.
Blind Index
HMAC-SHA256 blind indexes for exact-match search on encrypted fields without exposing plaintext.
import { createBlindIndexService } from '@octabits-io/foundation/pii';
const blindIndex = createBlindIndexService(process.env.BLIND_INDEX_KEY);
// Store alongside encrypted data for lookups
const emailIndex = blindIndex.generateIndex('[email protected]'); // Buffer (HMAC-SHA256)
// Later: WHERE email_blind_index = $1Master Key Provider
Envelope encryption pattern — encrypt data keys at rest with a master key derived via HKDF-SHA256.
import { createEnvVarMasterKeyProvider } from '@octabits-io/foundation/pii';
const masterKey = createEnvVarMasterKeyProvider(process.env.MASTER_KEY);
const wrapped = await masterKey.encrypt(dataKeyBuffer);
const unwrapped = await masterKey.decrypt(wrapped.value);MASTER_KEY must be cryptographically random material, not a passphrase — HKDF derives a fixed-size key but does no password stretching, so a human-chosen value is brute-forceable no matter how it's derived. Generate one with:
openssl rand -base64 32createEnvVarMasterKeyProvider throws at startup if the source is shorter than 32 characters. Note this is a length check only — it cannot detect a long-but-guessable passphrase, so always use a generated value.
Scoped Key Service
Per-scope key management: lazily generates an Age keypair + blind-index HMAC
key per scope, stores them master-key-encrypted, and serves decrypted keys
through a cache. Generic over the scope column — the consumer picks it. A
multi-tenant consumer binds the scope to its own tenantId column
(scope: { column: 'tenantId', value: tenantId }); a single-tenant or
differently-partitioned consumer picks orgId, workspaceId, ownerId, ….
Storage is a structural seam, not a database. The service depends on a
four-method ScopedKeyStore (insert / find / exists / destroy),
scope-bound at construction — it owns the encryption logic and knows nothing
about SQL, drivers, or ORMs (so @octabits-io/foundation/pii has no drizzle-orm
peer). The Postgres/Drizzle implementation of the seam ships separately as
createDrizzleScopedKeyStore in
@octabits-io/foundation/drizzle/scoped-key-store (column
shapes per that package's encryptionKeyColumns + a consumer-declared,
unique scope column); provide your own store to back it with anything else.
import { createScopedKeyService } from '@octabits-io/foundation/pii';
import { createDrizzleScopedKeyStore } from '@octabits-io/foundation/drizzle/scoped-key-store';
const scope = { column: 'orgId', value: orgId }; // consumer-chosen scope column
// A multi-tenant consumer binds the scope to its own tenantId column instead:
// const scope = { column: 'tenantId', value: tenantId };
const store = createDrizzleScopedKeyStore({
db, // structural Drizzle db (select/insert/delete)
table: schema.orgEncryptionKey, // your encryption-key table
scope, // same scope the service is bound to
});
const keyService = createScopedKeyService({
store,
scope,
masterKeyProvider,
cache, // e.g. LRU with ~5-minute TTL
});
const keys = await keyService.getKeys(); // lazy-generates on first use
// keys.value: { recipient, identity, blindIndexKey, keyVersion }
// Explicit generation inside the caller's transaction — re-bind the store to
// the tx so the write joins it. The cache is NOT pre-populated (the tx may
// still roll back); the next getKeys() populates it.
await db.transaction(async (tx) => {
await keyService.generateKeyPair(store.withDb(tx));
});
await keyService.hasKeys(); // Result<boolean, ScopedKeyError>
await keyService.destroyKeys(); // crypto-shredding: delete key row + drop cache
keyService.invalidateCache();The store maps its failures to two neutral outcomes — a lost unique race
(scoped_key_store_conflict, drives concurrent-generation recovery) vs any
other failure (scoped_key_store_failure) — which the service translates to its
Result-typed public errors: scoped_keys_not_found,
scoped_key_generation_error, scoped_key_storage_error, or a master-key error
(master_key_error / master_key_unsupported_plaintext).
Cache entries are keyed by column:value (URI-encoded). Don't share one cache instance across services whose stores persist keys in different tables under the same scope column and value — use one cache per key store.
Low-Level Primitives
import { encryptHybrid, decryptHybrid } from '@octabits-io/foundation/pii';
import { encryptSymmetric, decryptSymmetric, generateSymmetricKey } from '@octabits-io/foundation/pii';
// Age encryption (X25519 + ChaCha20-Poly1305)
const encrypted = await encryptHybrid('plaintext', 'age1...');
const decrypted = await decryptHybrid(encrypted.value, 'AGE-SECRET-KEY-1...');
// AES-256-GCM symmetric encryption
const key = generateSymmetricKey();
const enc = encryptSymmetric('plaintext', key);
const dec = decryptSymmetric(enc.value, key);@octabits-io/foundation/drizzle/*
(Formerly the standalone @octabits-io/drizzle-toolkit package.)
Shared Drizzle ORM utilities for PostgreSQL: database error handling, pagination,
a drizzle factory, a migration runner, generic CRUD service factories, a
scoped config store, RLS scoping, an idempotency-key store, and generic scope
schema primitives.
pg is an optional peer dependency — only the ./factory and ./migrate
subpaths need it at runtime (./rls uses its types only). Install pg in your
app when you use those modules; every other subpath works without it.
@octabits-io/foundation/drizzle/db
Database error handling and pagination helpers.
import {
withDbErrorHandling,
handleTransactionError,
TransactionRollbackError,
normalizePaginationLimit,
} from '@octabits-io/foundation/drizzle/db';
// Wrap DB operations — catches PG errors and returns Result<T, E | OctDatabaseError>
const result = await withDbErrorHandling(async () => {
await db.insert(users).values({ email });
return { ok: true, value: undefined };
});
// result.error.code → 'unique_violation' | 'foreign_key_violation' | ...
// Inside transactions — preserve typed errors through rollback
try {
await db.transaction(async (tx) => {
const result = await paymentService.create(tenantId, params, tx);
if (!result.ok) throw new TransactionRollbackError(result.error);
});
} catch (error) {
return handleTransactionError(error); // preserves typed error or maps PG error
}
// Pagination: limit=-1 → capped at 10,000
const dbLimit = normalizePaginationLimit(params.limit);@octabits-io/foundation/drizzle/factory
Drizzle instance factory over a pre-built pg.Pool, with schema augmentation
(db.tables.* / db.schema.*) and a .transaction() whose callback receives
an equally-augmented instance.
import { Pool } from 'pg';
import { createDrizzle } from '@octabits-io/foundation/drizzle/factory';
const pool = new Pool({ connectionString, max: 20 });
const db = createDrizzle(schema, { pool }); // optional: loggerAlso exported: createDrizzleFromClient (single PoolClient — for
request-scoped connections carrying session vars, e.g. RLS) and
augmentDrizzle (wrap an existing instance).
@octabits-io/foundation/drizzle/migrate
Migration runner for Drizzle SQL migrations.
import { runMigrations } from '@octabits-io/foundation/drizzle/migrate';
await runMigrations({ connectionString, migrationsFolder });
// optional: ssl, logger, sessionVars (GUCs set before migrate — e.g. RLS system mode)@octabits-io/foundation/drizzle/scope
Generic schema primitives for a scope-owner root plus per-scope keys and config — column-sets for three common base tables. A "scope" is whatever partitions your app (a tenant, workspace, organization, project, or nothing at all when single-tenant); the scope-reference column is yours to declare.
| Column-set | Purpose |
| ----------------------- | ----------------------------------------------------------------- |
| baseScopeColumns | The scope-owner root — generic columns only (id, name, isDisabled, createdAt). |
| encryptionKeyColumns | Per-scope PII encryption material (Age recipient + encrypted identity + blind-index key). Pairs with @octabits-io/foundation/pii — skip it if you don't use that package. |
| scopedConfigColumns | Key/value config columns (key, value jsonb, encrypted, audit) — add your own scope column and a (scopeColumn, key) PK. |
Only drizzle-orm/pg-core primitives are used — no framework or app imports.
Spread a column-set into your own pgTable(...) (the documented Drizzle
"reuse common column definitions"
pattern) to extend the base with domain columns. The tables, constraints, and
relations stay in your schema — the module ships no pgTable instances, so
your migrations never depend on a library-defined table. The
encryptionKeyColumns / scopedConfigColumns sets deliberately omit the scope
column so you own its name, type, FK, and PK placement:
import { pgTable, text, integer, primaryKey } from "drizzle-orm/pg-core";
import { baseScopeColumns, scopedConfigColumns } from "@octabits-io/foundation/drizzle/scope";
// Extend the scope-owner root with your domain columns (name it what you like):
export const tenant = pgTable("tenant", {
...baseScopeColumns, // id, name, isDisabled, createdAt
region: text("region").notNull(),
seatLimit: integer("seat_limit"),
});
// Add your scope column and declare the composite PK in the constraints callback:
export const tenantConfig = pgTable(
"tenant_config",
{
...scopedConfigColumns,
tenantId: text("tenant_id").notNull(), // your scope column
},
(t) => [primaryKey({ columns: [t.tenantId, t.key], name: "tenant_config_pk" })],
);Exports: bytea (custom bytea ↔ Buffer column type) and the three
column-sets (baseScopeColumns, encryptionKeyColumns, scopedConfigColumns).
@octabits-io/foundation/drizzle/crud
Generic CRUD service factories over any Drizzle table with an id column —
paginated list (+total), getById, create, update, delete, with
consistent keyed errors and optional created_by/updated_by audit stamping:
createBaseCrudService— no scoping.createScopedCrudService— every query auto-ANDed witheq(table[scope.column], scope.value);create()injects the scope column. Row isolation holds by construction (scope: { column, value }) — bind whatever column partitions your app ({ column: 'tenantId', value },{ column: 'workspaceId', value }, …).
@octabits-io/foundation/drizzle/scoped-key-store
The Drizzle adapter behind @octabits-io/foundation/pii's structural
ScopedKeyStore seam. pii owns the encryption logic but knows nothing about
SQL — it depends on a four-method store (insert / find / exists /
destroy), scope-bound at construction — so it carries no drizzle-orm
peer. This module is the Postgres/Drizzle implementation of that seam (the ORM
query logic lives here, where Drizzle is already a hard dep).
createDrizzleScopedKeyStore({ db, table, scope })— binds to one{ column, value }scope over an encryption-key table (spreadencryptionKeyColumnsfrom./scope+ a unique scope column).insertstamps the scope column and maps a lost unique race (SQLSTATE 23505, walked through the driver/ORMcausechain) toscoped_key_store_conflict;findselects the four key fields for the scope (ornull);exists/destroyare scoped by construction.store.withDb(tx)re-binds the same table + scope to a transaction so generation writes join the caller's tx.- The row/error types are structural duplicates of pii's — no cross-package
import (the same decoupling
./config'sConfigCipheruses). Wire it withcreateScopedKeyService({ store, scope, masterKeyProvider, cache }).
@octabits-io/foundation/drizzle/config
Generic config store over any key/value table (spread
scopedConfigColumns from ./scope): the validate → encrypt → cache →
default engine. Scoping is optional, mirroring ./crud's base-vs-scoped
split — no tenant vocabulary in the core.
createScopedConfigService—writeConfigvalidates each{ key, value }through a caller-suppliedschema, ciphersencryptedKeysinto a{ __encrypted: <base64> }envelope, and upserts every entry in one statement;readConfig(...keys)/readAll()decrypt, re-validate (so Zod defaults apply for absent rows), and cache. Generic over the caller's key→value map. Pass a{ column, value }scopeto partition rows (conflict target(scopeColumn, key)); omitscopefor an unscoped single-tenant store (conflict target(key)). The conflict target must match the table's primary key.- Encryption is an injected
cipher(raw-stringencrypt/decrypt) — no@octabits-io/foundation/piidependency; the engine owns the envelope + JSON. AreadConfigon an undecryptableencrypted=truerow throwsScopedConfigDecryptErrorrather than silently falling back to a default. createScopedConfigCachebuilds the optional cross-scope cache over a foundationLruCache, gated bycacheableKeys(transactional keys are never cached);readConfigalso keeps a request-scoped cache, both invalidated on write.
@octabits-io/foundation/drizzle/rls
Postgres row-level-security scoping, generic over the GUC key set:
createScopedDb(rawDb, gucs) (per-call-transaction proxy — every top-level
operation runs inside a short transaction that applies transaction-local
set_config(name, value, true) first; PgBouncer-safe), runWithGucs,
withSystemMode, the pinned-connection acquireScopedClient /
releaseScopedClient, and endPoolGracefully. Policies and concrete GUC
values stay in the consumer.
@octabits-io/foundation/drizzle/idempotency
Stripe-style X-Idempotency-Key store: createIdempotencyService —
begin() → cached / fresh (.commit(status, body)) / conflict, TTL expiry,
request-hash matching, race-safe unique-violation handling, opportunistic
cleanup. Scoping is optional (scope?: { column, value }); ships a spreadable
idempotencyKeyColumns column-set (add your own scope column when scoping).
Note:
./scopeabsorbed the former standalone@octabits-io/schemapackage. The former./testingmodule (testcontainers helpers, ex@octabits-io/drizzle-test) was removed — it had no consumers; copy it from git history if you need it. The former./workflowmodule (DAG workflow engine) has been superseded by@octabits-io/flow— a standalone durable workflow engine with a Postgres store and pg-boss dispatcher. Use that package instead.
@octabits-io/foundation/ical
(Formerly the standalone @octabits-io/ical package.)
iCal ingestion in two independent pieces: a fetcher that pulls a calendar
over http(s)/webcal with a timeout, a size cap, and a change-detection hash;
and a parser that expands VEVENTs/RRULEs into raw event ranges, with an
optional day-blocking collapse layer on top. Both are domain-free — no booking
or rental vocabulary in the base API.
@octabits-io/foundation (Result, OctError, Logger) and ical.js (v2)
are peer dependencies. Errors are foundation Result/OctError values (never
thrown); every error key is ical_*.
Fetcher
import { createICalFetcherService } from '@octabits-io/foundation/ical';
const fetcher = createICalFetcherService({
logger,
// all optional:
fetch: myPinnedFetch, // default globalThis.fetch
timeoutMs: 30_000, // default 30s
maxResponseBytes: 5_242_880, // default 5 MB
allowPrivateNetwork: false, // default false
});
const result = await fetcher.fetch('webcal://example.com/cal.ics', previousHash);
if (!result.ok) {
// result.error.key: 'ical_fetch_failed' | 'ical_fetch_timeout' | 'ical_too_large' | …
return;
}
const { data, hash, hasChanged } = result.value;webcal://is rewritten tohttps://; after that onlyhttp:/https:schemes are accepted — everything else (e.g.file:) is rejected.- URLs whose hostname is a literal private, loopback, or link-local IP
(127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 169.254/16,
::1,fc00::/7,fe80::/10, IPv4-mapped forms) are rejected unlessallowPrivateNetwork: trueis set. - SSRF note: the private-IP check only sees literal IPs. It cannot see
what a DNS name resolves to (DNS rebinding), and redirects are followed for
feed portability — a public URL may redirect to a private address. If you
need DNS-rebinding or redirect-to-private protection, inject a
fetchbound to a safe dispatcher (e.g. an undici Agent with a filteringlookup/connect). - Configurable timeout (default 30 s) via
AbortController— it covers both headers and body download. - Response cap (default 5 MB) counted in bytes: rejected early from
Content-Lengthwhen present, and enforced again while streaming the body — the download is aborted as soon as the cap is exceeded, never buffered in full. DTSTAMPlines (including RFC 5545 folded continuation lines) are stripped before hashing — calendar servers regenerate them on every export, so keeping them would make every fetch look like a change.- Userinfo (
user:pass@) is redacted from URLs before they appear in error messages or log metadata. - The hash is a fast non-cryptographic cyrb53 digest, used only for change
detection. Pass the previous hash to get
hasChanged; passnull/omit to always report changed.
| Error key | When |
| --- | --- |
| ical_url_invalid | Unparsable URL, or a scheme other than http(s)/webcal. |
| ical_url_private_network | Literal private/loopback/link-local IP hostname (without allowPrivateNetwork). |
| ical_fetch_failed | Non-2xx response (carries status). |
| ical_fetch_timeout | Request exceeded the timeout (default 30 s). |
| ical_too_large | Response exceeded the byte cap (default 5 MB). |
| (passthrough) | Network/other failures map through toOctError. |
Parser
Base API — raw event ranges
parseEventRanges returns each VEVENT occurrence as-is: inclusive start,
exclusive end (per iCal DTEND semantics), summary, uid, and an
allDay flag. RRULEs are expanded, capped at 5000 occurrences per event to
guard against pathological rules (e.g. FREQ=SECONDLY).
import { createICalParserService } from '@octabits-io/foundation/ical';
const parser = createICalParserService();
const ranges = parser.parseEventRanges(icalData, {
windowStart: new Date('2025-03-01'),
windowEnd: new Date('2025-03-31'), // bounds RRULE expansion
});
if (!ranges.ok) return;
for (const r of ranges.value) {
// { start: Date, end: Date, summary: string, uid: string, allDay: boolean }
}windowEnd bounds recurrence expansion and is strongly recommended for
recurring feeds. windowStart drops occurrences that already ended — those
pre-window occurrences do not count against the occurrence cap, so a
DTSTART years in the past still yields the current window (a separate internal
runaway guard bounds the skipping). maxOccurrencesPerEvent (default 5000)
overrides the safety cap.
Timezone caveat: ical.js bundles no IANA timezone data. TZID references
are only honoured when the feed ships a matching VTIMEZONE; otherwise the
timestamps are interpreted in the server's own zone. Absolute instants
(start/end) are therefore only reliable for UTC/floating times or feeds
that include their VTIMEZONEs — the startWallClock/endWallClock
components are always the event's own wall-clock reading and are safe
regardless.
Optional layer — day-blocking collapse
collapseToBlockedDateRanges is the opinionated layer on top: it collapses
events into blocked calendar-day ranges (YYYY-MM-DD, both ends inclusive)
within a window. All-day events map to their date span (exclusive DTEND, so the
last day is dropped); timed events are collapsed to whole days via an
hourThreshold heuristic (default 12) — a timed event starting before the
threshold also blocks the previous day, and one ending before it stops on the
previous day. Non-overlapping ranges are filtered out.
const blocked = parser.collapseToBlockedDateRanges(
icalData,
new Date('2025-03-01'),
new Date('2025-03-31'),
{ hourThreshold: 12 }, // optional
);
if (!blocked.ok) return;
// blocked.value: [{ start: '2025-03-09', end: '2025-03-10', summary: '…' }, …]The heuristic mirrors check-in/check-out style day blocking. Consumers that want
raw ranges (or a different collapse) build on parseEventRanges instead.
