@rebasepro/server 0.13.0 → 0.13.1-canary.g06dbe5b
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.
- package/dist/{admin_block-CcSGdH7M.js → admin_block-H86dCPsj.js} +62 -2
- package/dist/admin_block-H86dCPsj.js.map +1 -0
- package/dist/api/ast-schema-editor.d.ts +74 -6
- package/dist/api/errors.d.ts +0 -6
- package/dist/api/openapi-generator.d.ts +14 -0
- package/dist/api/rest/api-generator.d.ts +1 -1
- package/dist/api/rest/idempotency.d.ts +62 -4
- package/dist/api/rest/query-parser.d.ts +15 -1
- package/dist/api/rest/write-validation.d.ts +52 -9
- package/dist/auth/adapter-middleware.d.ts +3 -1
- package/dist/auth/apple-oauth.d.ts +16 -11
- package/dist/auth/bitbucket-oauth.d.ts +2 -4
- package/dist/auth/builtin-auth-adapter.d.ts +2 -0
- package/dist/auth/discord-oauth.d.ts +5 -6
- package/dist/auth/facebook-oauth.d.ts +2 -4
- package/dist/auth/github-oauth.d.ts +2 -4
- package/dist/auth/gitlab-oauth.d.ts +6 -4
- package/dist/auth/google-oauth.d.ts +1 -0
- package/dist/auth/index.d.ts +8 -1
- package/dist/auth/interfaces.d.ts +68 -1
- package/dist/auth/jwt.d.ts +40 -0
- package/dist/auth/linkedin-oauth.d.ts +2 -4
- package/dist/auth/mfa-gate.d.ts +42 -0
- package/dist/auth/mfa-routes.d.ts +26 -1
- package/dist/auth/mfa.d.ts +16 -0
- package/dist/auth/microsoft-oauth.d.ts +19 -4
- package/dist/auth/oauth-code-flow.d.ts +66 -0
- package/dist/auth/oauth-signin-policy.d.ts +61 -0
- package/dist/auth/oidc-id-token.d.ts +61 -0
- package/dist/auth/rate-limiter.d.ts +37 -2
- package/dist/auth/rls-scope.d.ts +25 -0
- package/dist/auth/routes.d.ts +8 -0
- package/dist/auth/slack-oauth.d.ts +2 -4
- package/dist/auth/spotify-oauth.d.ts +2 -4
- package/dist/auth/twitter-oauth.d.ts +2 -5
- package/dist/{auth-CuC9M2x6.js → auth-DkAHqvf5.js} +1604 -391
- package/dist/auth-DkAHqvf5.js.map +1 -0
- package/dist/{backup-CVggVhR2.js → backup-DLluVyuA.js} +2 -2
- package/dist/{backup-CVggVhR2.js.map → backup-DLluVyuA.js.map} +1 -1
- package/dist/boot/boot.d.ts +19 -0
- package/dist/boot/ddl-bootstrap.d.ts +70 -0
- package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-BB1U05sS.js} +3 -3
- package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-BB1U05sS.js.map} +1 -1
- package/dist/cron/cron-scheduler.d.ts +8 -3
- package/dist/cron/define-cron.d.ts +17 -3
- package/dist/{cron-loader-B1S2MCSl.js → cron-loader-3U5aGILy.js} +2 -2
- package/dist/{cron-loader-B1S2MCSl.js.map → cron-loader-3U5aGILy.js.map} +1 -1
- package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-rZgwlOz4.js} +2 -2
- package/dist/{cron-routes-CrQ0tK-_.js.map → cron-routes-rZgwlOz4.js.map} +1 -1
- package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-FJAaCAXm.js} +30 -5
- package/dist/cron-scheduler-FJAaCAXm.js.map +1 -0
- package/dist/{cron-store-BywZsyfZ.js → cron-store-9NmUDfzL.js} +66 -47
- package/dist/cron-store-9NmUDfzL.js.map +1 -0
- package/dist/ddl-bootstrap-BhXbTnBl.js +183 -0
- package/dist/ddl-bootstrap-BhXbTnBl.js.map +1 -0
- package/dist/email/html.d.ts +54 -0
- package/dist/email/index.d.ts +3 -0
- package/dist/email/link-base.d.ts +39 -0
- package/dist/email/smtp-email-service.d.ts +7 -1
- package/dist/email/templates.d.ts +9 -1
- package/dist/email/types.d.ts +11 -1
- package/dist/{errors-CgkCzoj7.js → errors-B1WZEdsK.js} +3 -11
- package/dist/errors-B1WZEdsK.js.map +1 -0
- package/dist/function-loader-D1SwtCa5.js +139 -0
- package/dist/function-loader-D1SwtCa5.js.map +1 -0
- package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
- package/dist/function-routes-Btcez1T-.js.map +1 -0
- package/dist/functions/define-function.d.ts +25 -6
- package/dist/functions/function-loader.d.ts +23 -0
- package/dist/functions/function-routes.d.ts +7 -1
- package/dist/functions/request-timeout.d.ts +34 -0
- package/dist/history/history-routes.d.ts +6 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.es.js +1960 -794
- package/dist/index.es.js.map +1 -1
- package/dist/init/docs.d.ts +10 -1
- package/dist/init/process-safety.d.ts +25 -0
- package/dist/init.d.ts +27 -0
- package/dist/{jwt-DD6EtpGj.js → jwt-Cuq6MwNy.js} +70 -8
- package/dist/jwt-Cuq6MwNy.js.map +1 -0
- package/dist/logger-DfvF_8r-.js +190 -0
- package/dist/logger-DfvF_8r-.js.map +1 -0
- package/dist/openapi-generator-BQxxxDpb.js +865 -0
- package/dist/openapi-generator-BQxxxDpb.js.map +1 -0
- package/dist/request-timeout-RivJsME0.js +65 -0
- package/dist/request-timeout-RivJsME0.js.map +1 -0
- package/dist/schema-editor-routes-DZFKGXgq.js +437 -0
- package/dist/schema-editor-routes-DZFKGXgq.js.map +1 -0
- package/dist/serve-spa.d.ts +5 -4
- package/dist/services/outbound-url-guard.d.ts +53 -0
- package/dist/services/routed-realtime-service.d.ts +10 -2
- package/dist/services/webhook-service.d.ts +76 -1
- package/dist/singleton.d.ts +25 -8
- package/dist/{src-Cum9kox5.js → src-By48Ffg0.js} +322 -70
- package/dist/src-By48Ffg0.js.map +1 -0
- package/dist/{src-_qQ3RNCK.js → src-Ca6NhxKs.js} +79 -2
- package/dist/src-Ca6NhxKs.js.map +1 -0
- package/dist/storage/LocalStorageController.d.ts +12 -1
- package/dist/storage/image-transform.d.ts +57 -0
- package/dist/storage/keys.d.ts +98 -0
- package/dist/storage/routes.d.ts +11 -0
- package/dist/storage/tus-handler.d.ts +7 -1
- package/dist/utils/logger.d.ts +18 -0
- package/dist/utils/logging.d.ts +0 -4
- package/dist/utils/sql.d.ts +11 -6
- package/package.json +7 -6
- package/dist/admin_block-CcSGdH7M.js.map +0 -1
- package/dist/auth-CuC9M2x6.js.map +0 -1
- package/dist/backend-CIxN4FVm.js +0 -15
- package/dist/backend-CIxN4FVm.js.map +0 -1
- package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
- package/dist/cron-store-BywZsyfZ.js.map +0 -1
- package/dist/errors-CgkCzoj7.js.map +0 -1
- package/dist/function-loader-B_1fYfUY.js +0 -86
- package/dist/function-loader-B_1fYfUY.js.map +0 -1
- package/dist/function-routes-C0cLIy3N.js.map +0 -1
- package/dist/jwt-DD6EtpGj.js.map +0 -1
- package/dist/logger-BYU66ENZ.js +0 -94
- package/dist/logger-BYU66ENZ.js.map +0 -1
- package/dist/openapi-generator-BEwyiaAr.js +0 -597
- package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
- package/dist/schema-editor-routes-CbF20Mf1.js +0 -248
- package/dist/schema-editor-routes-CbF20Mf1.js.map +0 -1
- package/dist/src-Cum9kox5.js.map +0 -1
- package/dist/src-_qQ3RNCK.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ddl-bootstrap-BhXbTnBl.js","names":[],"sources":["../../types/src/types/backend.ts","../../common/src/util/internal-tables.ts","../src/boot/ddl-bootstrap.ts"],"sourcesContent":["import type { CollectionConfig, FilterValues, WhereFilterOp } from \"./collections\";\nimport type { LogicalCondition } from \"../controllers/data\";\nimport type { AuthAdapter } from \"./auth_adapter\";\nimport type { HistoryConfig } from \"../controllers/client\";\nimport type { ChannelBusSetting } from \"./channel_bus\";\n\n// =============================================================================\n// DATABASE CONNECTION INTERFACES\n// =============================================================================\n\n/**\n * Abstract database connection interface.\n * Represents a connection to any database system.\n */\nexport interface DatabaseConnection {\n /**\n * Type identifier for this database (e.g., 'postgres', 'mongodb', 'mysql')\n */\n readonly type: string;\n\n /**\n * Whether the connection is currently active\n */\n readonly isConnected?: boolean;\n\n /**\n * Close the database connection and release resources.\n */\n close?(): Promise<void>;\n}\n\n// =============================================================================\n// QUERY BUILDING INTERFACES\n// =============================================================================\n\n/**\n * A single filter condition for database queries\n */\nexport interface QueryFilter {\n field: string;\n operator: WhereFilterOp;\n value: unknown;\n}\n\n/**\n * Options for fetching a collection of entities\n */\nexport interface FetchCollectionOptions<M extends Record<string, unknown> = Record<string, unknown>> {\n filter?: FilterValues<Extract<keyof M, string>>;\n orderBy?: string;\n order?: \"desc\" | \"asc\";\n limit?: number;\n offset?: number;\n startAfter?: unknown;\n searchString?: string;\n databaseId?: string;\n collection?: CollectionConfig;\n}\n\n/**\n * Options for searching entities\n */\nexport interface SearchOptions<M extends Record<string, unknown> = Record<string, unknown>> {\n filter?: FilterValues<Extract<keyof M, string>>;\n orderBy?: string;\n order?: \"desc\" | \"asc\";\n limit?: number;\n databaseId?: string;\n collection?: CollectionConfig;\n}\n\n/**\n * Options for counting entities\n */\nexport interface CountOptions<M extends Record<string, unknown> = Record<string, unknown>> {\n filter?: FilterValues<Extract<keyof M, string>>;\n /**\n * An `or(...)`/`and(...)` group, alongside `filter`.\n *\n * Counted as well as fetched, or `total` describes a different set of rows\n * from the one that was served — the same reason `filter` is here.\n */\n logical?: LogicalCondition;\n searchString?: string;\n databaseId?: string;\n}\n\n/**\n * Abstract condition builder interface.\n * Implementations translate Rebase filter conditions to database-specific queries.\n *\n * Note: This interface can be implemented as instance methods or as a class with static methods.\n * For static implementations (like DrizzleConditionBuilder), use the ConditionBuilderStatic type.\n *\n * @template T The type of condition returned by the builder (e.g., SQL for PostgreSQL, Filter<Document> for MongoDB)\n */\nexport interface ConditionBuilder<T = unknown> {\n /**\n * Build filter conditions from Rebase FilterValues\n */\n buildFilterConditions<M extends Record<string, unknown>>(\n filter: FilterValues<Extract<keyof M, string>>,\n collectionPath: string,\n ...args: unknown[]\n ): T[];\n\n /**\n * Build search conditions for text search\n */\n buildSearchConditions(\n searchString: string,\n properties: Record<string, unknown>,\n ...args: unknown[]\n ): T[];\n\n /**\n * Combine multiple conditions with AND operator\n */\n combineConditionsWithAnd(conditions: T[]): T | undefined;\n\n /**\n * Combine multiple conditions with OR operator\n */\n combineConditionsWithOr(conditions: T[]): T | undefined;\n}\n\n/**\n * Static condition builder type for implementations using static methods.\n * Use this type when the class provides static methods rather than instance methods.\n *\n * @example\n * // DrizzleConditionBuilder satisfies this type\n * const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;\n */\nexport type ConditionBuilderStatic<T = unknown> = {\n buildFilterConditions<M extends Record<string, unknown>>(\n filter: FilterValues<Extract<keyof M, string>>,\n ...args: unknown[]\n ): T[];\n buildSearchConditions(\n searchString: string,\n properties: Record<string, unknown>,\n ...args: unknown[]\n ): T[];\n combineConditionsWithAnd(conditions: T[]): T | undefined;\n combineConditionsWithOr(conditions: T[]): T | undefined;\n};\n\n// =============================================================================\n// ENTITY REPOSITORY INTERFACES\n// =============================================================================\n\n/**\n * Abstract entity repository interface.\n * Handles all CRUD operations for entities in the database.\n *\n * Implementations should handle:\n * - Entity serialization/deserialization\n * - Relation resolution\n * - ID generation and conversion\n */\nexport interface DataRepository {\n /**\n * Fetch a single entity by ID\n */\n fetchOne<M extends Record<string, unknown>>(\n collectionPath: string,\n id: string | number,\n databaseId?: string\n ): Promise<Record<string, unknown> | undefined>;\n\n /**\n * Fetch a collection of entities with optional filtering, ordering, and pagination\n */\n fetchCollection<M extends Record<string, unknown>>(\n collectionPath: string,\n options?: FetchCollectionOptions<M>\n ): Promise<Record<string, unknown>[]>;\n\n /**\n * Search entities by text\n */\n searchRows<M extends Record<string, unknown>>(\n collectionPath: string,\n searchString: string,\n options?: SearchOptions<M>\n ): Promise<Record<string, unknown>[]>;\n\n /**\n * Count entities in a collection\n */\n count<M extends Record<string, unknown>>(\n collectionPath: string,\n options?: CountOptions<M>\n ): Promise<number>;\n\n /**\n * Save a entity (create or update)\n */\n save<M extends Record<string, unknown>>(\n collectionPath: string,\n values: Partial<M>,\n id?: string | number,\n databaseId?: string\n ): Promise<Record<string, unknown>>;\n\n /**\n * Delete a entity by ID\n */\n delete(\n collectionPath: string,\n id: string | number,\n databaseId?: string\n ): Promise<void>;\n\n /**\n * Check if a field value is unique in a collection\n */\n checkUniqueField(\n collectionPath: string,\n fieldName: string,\n value: unknown,\n excludeEntityId?: string,\n databaseId?: string\n ): Promise<boolean>;\n\n}\n\n// =============================================================================\n// REALTIME INTERFACES\n// =============================================================================\n\n/**\n * Configuration for subscribing to a collection\n */\nexport interface CollectionSubscriptionConfig {\n clientId: string;\n path: string;\n filter?: unknown;\n orderBy?: string;\n order?: \"desc\" | \"asc\";\n limit?: number;\n startAfter?: unknown;\n databaseId?: string;\n searchString?: string;\n /** Ask each row which declared search field matched. */\n searchExplain?: boolean;\n}\n\n/**\n * Configuration for subscribing to a single entity\n */\nexport interface SingleSubscriptionConfig {\n clientId: string;\n path: string;\n id: string | number;\n}\n\n/**\n * Opt-in retention for one set of broadcast channels.\n *\n * Retention is configured on the server and nowhere else. A channel is created\n * by whoever names it, so letting a client ask for its own history depth would\n * let any visitor commit the backend to unbounded storage; and presence-only or\n * notification-only channels — the overwhelming majority — must not pay for a\n * feature they never use. With no rules configured nothing is written, no table\n * is created, and broadcast behaves exactly as it did before history existed.\n */\nexport interface ChannelRetentionRule {\n /**\n * Channel name to match. Either exact (`\"doc:42\"`) or a trailing-`*` prefix\n * (`\"doc:*\"`). Deliberately not a full glob or RegExp: this decides what\n * gets written to disk, and a rule whose blast radius is not obvious at a\n * glance is the wrong shape for that.\n */\n match: string;\n /** Keep at most this many of the most recent messages per channel. */\n limit?: number;\n /**\n * Keep messages for at most this long. Accepts a millisecond count or a\n * short duration string (`\"30s\"`, `\"15m\"`, `\"24h\"`, `\"7d\"`).\n */\n ttl?: number | string;\n}\n\n/**\n * Server-side realtime options.\n *\n * The channel bus contract and its config live in `./channel_bus` so that a\n * transport shipped as its own package depends on the contract alone.\n */\nexport interface RealtimeChannelsConfig {\n /**\n * Retention rules, most specific first — the first match wins. Omitted or\n * empty means no channel retains anything.\n */\n channels?: ChannelRetentionRule[];\n /**\n * How channel broadcast and presence reach other backend instances.\n * Defaults to `{ type: \"memory\" }` — i.e. they don't.\n */\n bus?: ChannelBusSetting;\n}\n\n/**\n * Abstract realtime provider interface.\n * Handles real-time subscriptions and notifications for entity changes.\n */\nexport interface RealtimeProvider {\n /**\n * Subscribe to collection changes\n */\n subscribeToCollection(\n subscriptionId: string,\n config: CollectionSubscriptionConfig,\n callback?: (rows: Record<string, unknown>[]) => void\n ): void;\n\n /**\n * Subscribe to single entity changes\n */\n subscribeToOne(\n subscriptionId: string,\n config: SingleSubscriptionConfig,\n callback?: (row: Record<string, unknown> | null) => void\n ): void;\n\n /**\n * Unsubscribe from a subscription\n */\n unsubscribe(subscriptionId: string): void;\n\n /**\n * Notify all relevant subscribers of a entity update\n */\n notifyUpdate(\n path: string,\n id: string,\n row: Record<string, unknown> | null,\n databaseId?: string\n ): Promise<void>;\n\n /**\n * Called when the HTTP server is ready and listening.\n * Useful for providers that need the server address for callbacks.\n */\n onServerReady?(serverInfo: { port: number; hostname?: string }): void;\n\n /**\n * Gracefully shut down the realtime provider.\n * Called during server shutdown to clean up resources.\n */\n destroy?(): Promise<void>;\n\n /**\n * Stop the internal LISTEN client (e.g., PostgreSQL LISTEN/NOTIFY).\n * Called during graceful shutdown before closing database connections.\n */\n stopListening?(): Promise<void>;\n}\n\n// =============================================================================\n// COLLECTION REGISTRY INTERFACES\n// =============================================================================\n\n/**\n * Abstract collection registry interface.\n * Manages registration and lookup of entity collections.\n */\nexport interface CollectionRegistryInterface {\n /**\n * Register a collection\n */\n register(collection: CollectionConfig): void;\n\n /**\n * Get a collection by its path\n */\n getCollectionByPath(path: string): CollectionConfig | undefined;\n\n /**\n * Get all registered collections\n */\n getCollections(): CollectionConfig[];\n\n /**\n * Get the currently registered global callbacks, if any.\n */\n getGlobalCallbacks(): any | undefined;\n}\n\n// =============================================================================\n// DATA TRANSFORMER INTERFACES\n// =============================================================================\n\n/**\n * Abstract data transformer interface.\n * Handles serialization/deserialization between frontend and database formats.\n */\nexport interface DataTransformer {\n /**\n * Transform entity data for storage in the database\n */\n serializeToDatabase<M extends Record<string, unknown>>(\n entity: M,\n collection: CollectionConfig\n ): Record<string, unknown>;\n\n /**\n * Transform database data back to entity format\n */\n deserializeFromDatabase<M extends Record<string, unknown>>(\n data: Record<string, unknown>,\n collection: CollectionConfig\n ): Promise<M>;\n}\n\n// =============================================================================\n// DATABASE ADMIN — CAPABILITY-SPECIFIC INTERFACES (1.3)\n// =============================================================================\n\n/**\n * Administrative operations for SQL-based databases (PostgreSQL, MySQL, etc.).\n * Used by the SQL Editor, RLS Editor, and schema browser.\n *\n * @group Admin\n */\nexport interface SQLAdmin {\n /**\n * Execute raw SQL against the database.\n */\n executeSql(sql: string, options?: { database?: string; role?: string; params?: unknown[] }): Promise<Record<string, unknown>[]>;\n\n /**\n * Fetch the available databases on the server.\n */\n fetchAvailableDatabases?(): Promise<string[]>;\n\n /**\n * Fetch the available *native PostgreSQL* database roles (from `pg_roles`).\n *\n * These are connection-level roles — what the SQL editor can `SET ROLE` to,\n * and what `SecurityRule.pgRoles` targets. They are NOT application roles;\n * for those use {@link fetchApplicationRoles}.\n */\n fetchAvailableRoles?(): Promise<string[]>;\n\n /**\n * Fetch the *application-level* roles in use in this project.\n *\n * These are the strings stored on the users table's `roles` column and\n * exposed to policies as `auth.roles()` — what `SecurityRule.roles`\n * matches against. Distinct from {@link fetchAvailableRoles}; the two are\n * not interchangeable.\n */\n fetchApplicationRoles?(): Promise<string[]>;\n\n /**\n * Fetch the current database name.\n */\n fetchCurrentDatabase?(): Promise<string | undefined>;\n}\n\n/**\n * Administrative operations for document-based databases (MongoDB, Firestore, etc.).\n * Used by future document administration tools.\n *\n * @group Admin\n */\nexport interface DocumentAdmin {\n /**\n * Execute an aggregation pipeline or equivalent query.\n */\n executeAggregate?(pipeline: Record<string, unknown>[]): Promise<Record<string, unknown>[]>;\n\n /**\n * Fetch statistics for a collection (document count, size, etc.).\n */\n fetchCollectionStats?(collectionName: string): Promise<{ count: number; sizeBytes?: number }>;\n}\n\n/**\n * Administrative operations for schema management.\n * Shared across SQL and document databases.\n *\n * @group Admin\n */\nexport interface SchemaAdmin {\n /**\n * Fetch database tables/collections not yet mapped to a Rebase collection.\n */\n fetchUnmappedTables?(mappedPaths?: string[]): Promise<string[]>;\n\n /**\n * Fetch column/field metadata for a single table/collection.\n * The return type is generic — SQL backends return TableMetadata,\n * document backends may return a different shape.\n */\n fetchTableMetadata?(tableName: string): Promise<unknown>;\n}\n\n/**\n * Metadata for a database branch.\n * @group Admin\n */\nexport interface BranchInfo {\n /** Branch name (without prefix). */\n name: string;\n /** The database this branch was created from. */\n parentDatabase: string;\n /** When the branch was created. */\n createdAt: Date;\n /** Size in bytes, if available from the server. */\n sizeBytes?: number;\n}\n\n/**\n * Administrative operations for database branching.\n * Allows creating isolated database copies for development/preview workflows.\n *\n * @group Admin\n */\nexport interface BranchAdmin {\n /** Create a new branch (database copy) from the current or specified source database. */\n createBranch(name: string, options?: { source?: string }): Promise<BranchInfo>;\n\n /** Delete a branch database. Cannot delete the main/default database. */\n deleteBranch(name: string): Promise<void>;\n\n /** List all branches (databases that were created via branching). */\n listBranches(): Promise<BranchInfo[]>;\n\n /** Get info about a specific branch. */\n getBranchInfo(name: string): Promise<BranchInfo | undefined>;\n}\n\n/**\n * Union type for all admin capabilities.\n * A backend may implement any combination of these interfaces.\n *\n * Use type guards (`isSQLAdmin`, `isDocumentAdmin`, `isSchemaAdmin`, `isBranchAdmin`)\n * to safely narrow the type before calling methods.\n *\n * @group Admin\n */\nexport type DatabaseAdmin = Partial<SQLAdmin> & Partial<DocumentAdmin> & Partial<SchemaAdmin> & Partial<BranchAdmin>;\n\n/**\n * Type guard: does this admin support SQL operations?\n * @group Admin\n */\nexport function isSQLAdmin(admin: DatabaseAdmin | undefined): admin is SQLAdmin {\n return !!admin && typeof (admin as SQLAdmin).executeSql === \"function\";\n}\n\n/**\n * Type guard: does this admin support document operations?\n * @group Admin\n */\nexport function isDocumentAdmin(admin: DatabaseAdmin | undefined): admin is DocumentAdmin {\n return !!admin && (\n typeof (admin as DocumentAdmin).executeAggregate === \"function\" ||\n typeof (admin as DocumentAdmin).fetchCollectionStats === \"function\"\n );\n}\n\n/**\n * Type guard: does this admin support schema management?\n * @group Admin\n */\nexport function isSchemaAdmin(admin: DatabaseAdmin | undefined): admin is SchemaAdmin {\n return !!admin && (\n typeof (admin as SchemaAdmin).fetchUnmappedTables === \"function\" ||\n typeof (admin as SchemaAdmin).fetchTableMetadata === \"function\"\n );\n}\n\n/**\n * Type guard: does this admin support database branching?\n * @group Admin\n */\nexport function isBranchAdmin(admin: DatabaseAdmin | undefined): admin is BranchAdmin {\n return !!admin && typeof (admin as BranchAdmin).createBranch === \"function\";\n}\n\n// =============================================================================\n// LIFECYCLE INTERFACES (1.4)\n// =============================================================================\n\n/**\n * Health check result returned by `healthCheck()`.\n * @group Lifecycle\n */\nexport interface HealthCheckResult {\n /** Whether the backend is healthy and able to serve requests. */\n healthy: boolean;\n /** Round-trip latency to the database in milliseconds. */\n latencyMs: number;\n /** Optional details (e.g., pool stats, replication lag). */\n details?: Record<string, unknown>;\n}\n\n/**\n * Lifecycle contract for backend components that hold resources\n * (database connections, WebSocket pools, timers, etc.).\n *\n * All methods are optional — simple backends (e.g., in-memory) can skip them.\n * @group Lifecycle\n */\nexport interface BackendLifecycle {\n /**\n * Initialize the backend: open connections, run migrations, seed data.\n * Called once during startup. Idempotent.\n */\n initialize?(): Promise<void>;\n\n /**\n * Check whether the backend is healthy and reachable.\n * Should be fast (< 1 s) and safe to call frequently.\n */\n healthCheck?(): Promise<HealthCheckResult>;\n\n /**\n * Gracefully shut down: close connections, flush buffers, cancel timers.\n * After calling `destroy()`, no other methods should be called.\n */\n destroy?(): Promise<void>;\n}\n\n// =============================================================================\n// BACKEND FACTORY INTERFACES\n// =============================================================================\n\n/**\n * Configuration for creating a database backend\n */\nexport interface BackendConfig {\n /**\n * Type of database backend\n */\n type: string;\n\n /**\n * Database connection (implementation-specific)\n */\n connection: unknown;\n\n /**\n * Schema definition (implementation-specific, e.g., Drizzle schema for PostgreSQL)\n */\n schema?: unknown;\n}\n\n/**\n * A complete backend instance with all required services.\n *\n * Now includes optional lifecycle management and admin capabilities.\n */\nexport interface BackendInstance extends BackendLifecycle {\n /**\n * Entity repository for CRUD operations\n */\n entityRepository: DataRepository;\n\n /**\n * Realtime provider for subscriptions\n */\n realtimeProvider: RealtimeProvider;\n\n /**\n * Collection registry\n */\n collectionRegistry: CollectionRegistryInterface;\n\n /**\n * The underlying database connection\n */\n connection: DatabaseConnection;\n\n /**\n * Administrative operations (SQL, schema, documents).\n * What's available depends on the backend type — use type guards\n * (`isSQLAdmin`, `isSchemaAdmin`, etc.) to narrow.\n */\n admin?: DatabaseAdmin;\n}\n\n/**\n * Factory function type for creating backend instances\n */\nexport type BackendFactory<TConfig extends BackendConfig = BackendConfig> =\n (config: TConfig) => BackendInstance;\n\n// =============================================================================\n// BACKEND BOOTSTRAPPER (1.2)\n// =============================================================================\n\n/**\n * A `BackendBootstrapper` encapsulates all driver-specific initialization logic.\n *\n * Instead of hard-coding Postgres setup into `initializeRebaseBackend()`,\n * each database backend provides its own bootstrapper that knows how to:\n * - Create the DataDriver from a config object\n * - Optionally initialize auth tables\n * - Optionally create a realtime service\n * - Mount driver-specific API routes\n *\n * The main `initializeRebaseBackend()` becomes a **coordinator** that iterates\n * registered bootstrappers, calls their hooks, and wires the results together.\n *\n * @group Backend\n *\n * @example\n * ```typescript\n * // Third-party MySQL bootstrapper\n * const mysqlBootstrapper: BackendBootstrapper = {\n * type: \"mysql\",\n * initializeDriver: async (config) => new MySQLDataDriver(config.connection),\n * initializeRealtime: async (config) => new MySQLChangeStreamRealtime(config.connection),\n * };\n *\n * initializeRebaseBackend({\n * ...config,\n * bootstrappers: [postgresBootstrapper, mysqlBootstrapper]\n * });\n * ```\n */\nexport interface BackendBootstrapper {\n /**\n * Which driver type this bootstrapper handles.\n * Must match the `type` field on the driver config object\n * (e.g., `\"postgres\"`, `\"mongodb\"`, `\"mysql\"`).\n */\n type: string;\n\n /**\n * Unique identifier for this bootstrapper instance.\n * Used to register the driver in the driver registry.\n * Defaults to `type` if not set.\n */\n id?: string;\n\n /**\n * Whether this bootstrapper provides the default driver.\n * When true, the coordinator uses this driver as the primary one.\n */\n isDefault?: boolean;\n\n /**\n * Run database migrations for this driver.\n * Called by the coordinator after all drivers are initialized.\n */\n runMigrations?(config: unknown, driverResult: InitializedDriver): Promise<void>;\n\n /**\n * Create a DataDriver from the given config.\n * This is the only **required** method.\n */\n initializeDriver(config: unknown): Promise<InitializedDriver>;\n\n /**\n * Initialize auth tables / services if this driver supports them.\n * Return undefined if auth is not supported by this backend.\n */\n initializeAuth?(config: unknown, driverResult: InitializedDriver): Promise<BootstrappedAuth | undefined>;\n\n /**\n * Initialize history tables / services if this driver supports them.\n * Return undefined if history is not supported by this backend.\n */\n initializeHistory?(config: HistoryConfig, driverResult: InitializedDriver): Promise<{ historyService: unknown } | undefined>;\n\n /**\n * Create a realtime provider for this driver.\n * Return undefined if the driver does not support realtime.\n */\n initializeRealtime?(config: unknown, driverResult: InitializedDriver): Promise<RealtimeProvider | undefined>;\n\n /**\n * Mount any driver-specific HTTP routes (e.g., custom admin endpoints).\n * Called after all drivers are initialized.\n */\n mountRoutes?(app: unknown, basePath: string, driverResult: InitializedDriver): void;\n\n /**\n * Return admin capabilities for this driver.\n */\n getAdmin?(driverResult: InitializedDriver): DatabaseAdmin | undefined;\n\n /**\n * Bring the database's collection tables up to date, additively.\n *\n * Optional because it is only meaningful for schema-ful drivers. A managed\n * runtime boots a compiled project against a database it has never seen; auth\n * tables are ensured on boot but collection tables were created by nothing,\n * so every data request answered 500 on a missing relation. The CLI's `db\n * push` cannot fill the gap — it needs Atlas, and the runtime image ships no\n * CLI.\n *\n * Implementations MUST be additive-only: create missing tables, columns and\n * enum types, and never drop, narrow or rewrite anything. This runs\n * unattended against live customer data with nobody reading a diff, so the\n * destructive half stays a deliberate migration.\n */\n ensureCollectionSchema?(\n collections: unknown[],\n driverResult: InitializedDriver,\n log?: (message: string) => void\n ): Promise<{ applied: number }>;\n\n /**\n * Apply the collections' row-level-security policies, additively and\n * idempotently — the companion to {@link ensureCollectionSchema}.\n *\n * That method creates the tables; a table with RLS disabled and no policies\n * is not servable, because authenticated requests run as a restricted role:\n * a read with no `SELECT` policy returns nothing (a public collection\n * answers 401) and a write with no `INSERT`/`UPDATE` policy is denied. The\n * `db push` CLI applies these from the same collections, but it cannot reach\n * a managed tenant's in-cluster database — the runtime, already connected,\n * is the only thing that can.\n *\n * MUST be idempotent (re-run on every boot) and MUST NOT be destructive.\n * Runs after auth initialization, because the generated policies call the\n * `auth.*` helper functions and `CREATE POLICY` validates they exist.\n */\n ensureCollectionPolicies?(\n collections: unknown[],\n driverResult: InitializedDriver,\n log?: (message: string) => void\n ): Promise<{ applied: number }>;\n\n /**\n * Initialize WebSocket server for realtime operations.\n */\n initializeWebsockets?(server: unknown, realtimeService: RealtimeProvider, driver: import(\"../controllers/data_driver\").DataDriver, config?: unknown, authAdapter?: AuthAdapter): Promise<void> | void;\n}\n\n/**\n * Result of `BackendBootstrapper.initializeDriver()`.\n * @group Backend\n */\nexport interface InitializedDriver {\n /** The DataDriver instance, ready for use. */\n driver: import(\"../controllers/data_driver\").DataDriver;\n\n /** The realtime service, if the driver created one during init. */\n realtimeProvider?: RealtimeProvider;\n\n /** A collection registry to register schema / tables into. */\n collectionRegistry?: CollectionRegistryInterface;\n\n /**\n * Collections the driver derived from the live database schema.\n *\n * Set by drivers that introspect in `baas` mode; the server serves these\n * instead of collections loaded from config files.\n */\n collections?: import(\"./collections\").CollectionConfig[];\n\n /** The underlying database connection (for lifecycle management). */\n connection?: DatabaseConnection;\n\n /**\n * Opaque handle that the bootstrapper can use in subsequent hooks\n * (e.g., `initializeAuth`, `mountRoutes`) to access driver internals.\n * Not used by the coordinator.\n */\n internals?: unknown;\n}\n\n/**\n * Result of `BackendBootstrapper.initializeAuth()`.\n * @group Backend\n */\nexport interface BootstrappedAuth {\n /** User management service. */\n userService: unknown;\n /** Role management service (optional, roles are now simple strings). */\n roleService?: unknown;\n /** Email service (optional). */\n emailService?: unknown;\n /** Combined Auth Repository for unified token and user management. */\n authRepository?: unknown;\n /**\n * Whether the auth schema in the database is one this runtime can serve.\n *\n * Folded into `healthCheck()` so a schema mismatch shows up as a degraded\n * health response. Without it, a server whose auth is entirely broken still\n * reports healthy — the database connection it probes is fine, and the\n * mismatch is only discovered one failed login at a time.\n */\n schemaHealthCheck?(): Promise<AuthSchemaHealth>;\n}\n\n/**\n * Result of {@link BootstrappedAuth.schemaHealthCheck}.\n * @group Lifecycle\n */\nexport interface AuthSchemaHealth {\n /** False when this runtime cannot be trusted to serve auth against this database. */\n healthy: boolean;\n /** Human-readable descriptions of each mismatch found. Empty when healthy. */\n problems: string[];\n /** Auth schema version recorded in the database, when it records one. */\n databaseVersion?: number | null;\n /** Auth schema version this runtime expects. */\n runtimeVersion?: number;\n}\n","/**\n * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the\n * end-user role away from them.\n *\n * ## Why this exists\n *\n * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role\n * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table\n * in the schemas a project uses — including `rebase`, because a project's own\n * collections are allowed to live there (the scaffold puts `users` there). It\n * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the\n * migrating role inherits the same grant.\n *\n * Every framework-internal table is created later: auth's tables come up during\n * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first\n * job registers, `idempotency_keys` on the first request that carries a key. So\n * they all inherited full DML for the end-user role — and none of them enables\n * row-level security, because none of them is a collection with\n * `securityRules`. Measured on a freshly provisioned database, `SET ROLE\n * rebase_user` could read `rebase.refresh_tokens` (session token hashes),\n * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and\n * `rebase.api_keys` (including its `admin` flag), and insert into\n * `rebase.app_config`.\n *\n * Nothing routes a user-context query at those tables today, so this was not\n * reachable over the API. That is the wrong thing to depend on: the documented\n * model is that RLS is the authorization boundary, and these tables sat outside\n * it. The boundary is now a privilege boundary instead — the role simply cannot\n * address them.\n *\n * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY\n *\n * RLS with no policy denies every row, which is the same outcome, but it is the\n * *weaker* statement: it leaves the grant in place, so a later policy — or a\n * `FORCE` flag cleared by some future migration — reopens the table. There is no\n * row of `refresh_tokens` any end user should ever reach, so the honest encoding\n * is \"this role has no privilege here at all\". It also keeps the owner\n * connection (which auth actually runs on) completely unaffected.\n *\n * ## Keeping it true\n *\n * `packages/rls-check` scans the `rebase` schema — it used to skip it as a\n * \"platform\" schema — and its `rls-disabled` check fires on exactly the\n * condition this module removes: RLS off *and* a DML grant to a reachable role.\n * So a table added here without a revoke is caught by `pnpm rls:check`, not by\n * someone re-reading this file.\n */\n\n/**\n * The Postgres role authenticated requests run as.\n *\n * Defined here rather than in the Postgres driver because both the driver (which\n * provisions the role) and this module (which revokes on its behalf) need it,\n * and a second spelling of a role name is a silent no-op waiting to happen.\n */\nexport const REBASE_USER_ROLE = \"rebase_user\";\n\n/**\n * Framework-internal table names, unqualified.\n *\n * Deliberately NOT including `users`: the auth user table is also a collection,\n * with `securityRules`, RLS enabled and policies applied. Users read their own\n * row through it — revoking there would break sign-in.\n *\n * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`\n * because `db migrate apply` passes `--revisions-schema rebase`.\n */\nexport const REBASE_INTERNAL_TABLES: readonly string[] = [\n // auth\n \"user_identities\",\n \"refresh_tokens\",\n \"password_reset_tokens\",\n \"magic_link_tokens\",\n \"mfa_factors\",\n \"mfa_challenges\",\n \"recovery_codes\",\n \"app_config\",\n \"schema_meta\",\n // platform services\n \"api_keys\",\n \"cron_logs\",\n \"cron_claims\",\n \"idempotency_keys\",\n \"entity_history\",\n \"branches\",\n // realtime channels — authorization for these lives in the channel rules the\n // server evaluates before it reads or writes, never in a row policy\n \"channel_messages\",\n \"channel_cursors\",\n \"channel_presence\",\n // migration bookkeeping\n \"atlas_schema_revisions\"\n];\n\n/** Postgres identifiers this module is willing to interpolate. */\nconst SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;\n\n/**\n * A single statement that takes every privilege on `schema.table` away from the\n * end-user role.\n *\n * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which\n * happen in practice:\n *\n * - the role does not exist when the connection is unprivileged (Rebase then\n * relies on native RLS rather than a role switch), and a bare `REVOKE` on a\n * missing role is an error, not a no-op;\n * - the table may not exist yet — `cron_logs` never appears in a project with\n * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.\n *\n * One command, so it is safe on handles that speak the extended query protocol\n * and reject multi-statement strings.\n */\nexport function revokeInternalTableSql(schema: string, table: string): string {\n if (!SAFE_IDENTIFIER.test(schema)) {\n throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);\n }\n if (!SAFE_IDENTIFIER.test(table)) {\n throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);\n }\n const qualified = `\"${schema}\".\"${table}\"`;\n return `\n DO $rebase_revoke$\n BEGIN\n IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')\n AND to_regclass('${qualified}') IS NOT NULL THEN\n EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';\n END IF;\n END\n $rebase_revoke$;\n `.trim();\n}\n\n/**\n * Revoke on every internal table in `schema`, one statement at a time.\n *\n * Best-effort per table: a connection that does not own one of them (a\n * pre-provisioned database, a platform-managed ledger) cannot revoke on it, and\n * that must not take down a boot. The caller decides how loud to be — `onError`\n * exists so the driver can warn without this module importing a logger.\n */\nexport async function revokeInternalTableAccess(\n execute: (sql: string) => Promise<unknown>,\n schema: string,\n options?: { tables?: readonly string[]; onError?: (table: string, error: unknown) => void }\n): Promise<void> {\n for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) {\n try {\n await execute(revokeInternalTableSql(schema, table));\n } catch (error) {\n options?.onError?.(table, error);\n }\n }\n}\n","import { logger } from \"../utils/logger.js\";\n\n/**\n * Helpers for the \"create my internal table if it isn't there yet\" bootstrap\n * that several stores run at boot.\n *\n * These all look trivially safe — every statement is `IF NOT EXISTS` — and they\n * are not, for two reasons that only show up with more than one app instance:\n *\n * 1. `CREATE … IF NOT EXISTS` reads the catalog and then writes to it, and\n * those two steps are not one atomic operation. Instances starting together\n * — a rolling deploy, a replica count going from 1 to N, a crash loop\n * restarting the fleet — can both see \"absent\" and both try to create. The\n * loser gets a duplicate key on a *catalog* index instead of the silent\n * no-op the syntax appears to promise. Measured against Postgres 18: with\n * five instances booting at once, 8 of 10 `ensureTable` calls hit it.\n *\n * 2. A bootstrap written as one long `try` block therefore abandons everything\n * after the losing statement — including, in every store that has one, the\n * `REVOKE` that takes the table back off the end-user role. That revoke is\n * a security control and must not be collateral damage from a race.\n *\n * So: retry the race, contain each statement separately, and decide what to do\n * next from what actually exists rather than from who won.\n */\n\n/** A driver's `executeSql`, narrowed to what a bootstrap needs. */\nexport type SqlExec = (sqlText: string, options?: { params?: unknown[] }) => Promise<Record<string, unknown>[]>;\n\n/**\n * SQLSTATEs a *simultaneous boot* can raise from statements that are otherwise\n * idempotent. Retrying is always the right answer for these: the second attempt\n * finds the object present and does nothing.\n */\nexport const CONCURRENT_DDL_SQLSTATES = new Set([\n \"23505\", // unique_violation on pg_type / pg_class / pg_namespace\n \"42P06\", // duplicate_schema\n \"42P07\", // duplicate_table\n \"42710\", // duplicate_object — an index or a constraint\n \"40P01\" // deadlock_detected — two boots taking catalog locks in step\n]);\n\n/** Attempts per idempotent DDL statement, including the first. */\nexport const DDL_ATTEMPTS = 4;\nconst DDL_RETRY_BASE_MS = 40;\n\n/**\n * Walk an error's `cause` chain, stopping at the first link `visit` accepts.\n * Drizzle wraps the driver error, so nothing useful is ever on the top level.\n */\nexport function hasInCauseChain(err: unknown, visit: (e: Record<string, unknown>) => boolean): boolean {\n let current: unknown = err;\n for (let depth = 0; depth < 10 && current; depth++) {\n if (typeof current !== \"object\") break;\n const e = current as Record<string, unknown>;\n if (visit(e)) return true;\n current = e.cause;\n }\n return false;\n}\n\n/**\n * Is this the loser of a race to create something that already exists?\n *\n * Deliberately narrow. A permission failure, an unreachable database or a typo\n * in the DDL must surface on the first attempt rather than being retried four\n * times and then reported as a race that never was.\n */\nexport function isConcurrentDdlRace(err: unknown): boolean {\n return hasInCauseChain(err, (e) =>\n (typeof e.code === \"string\" && CONCURRENT_DDL_SQLSTATES.has(e.code)) ||\n // SQLite and MySQL say it in words rather than in a shared SQLSTATE.\n (typeof e.message === \"string\" && /already exists/i.test(e.message))\n );\n}\n\nexport interface DdlBootstrapper {\n /**\n * Run one idempotent statement — `CREATE … IF NOT EXISTS`, `ALTER TABLE …\n * ADD COLUMN IF NOT EXISTS` — retrying the catalog race a simultaneous boot\n * produces. Never throws: a statement that cannot be made to work is logged\n * and the caller carries on to the next one.\n */\n ensureObject(label: string, sqlText: string): Promise<void>;\n\n /** Contain one step's failure so that the steps after it still run. */\n step(label: string, run: () => Promise<unknown>): Promise<void>;\n\n /**\n * Is this table there and readable? Asked with a query any SQL dialect\n * answers, rather than `to_regclass`, so a future non-Postgres SQL driver\n * gets a real answer instead of a syntax error read as \"missing\".\n */\n isReadable(table: string): Promise<boolean>;\n}\n\n/**\n * @param exec the driver's SQL escape hatch\n * @param scope log prefix identifying the caller, e.g. `\"cron-store\"`\n */\nexport function createDdlBootstrapper(exec: SqlExec, scope: string): DdlBootstrapper {\n /** Jittered, so peers that collided once do not collide again in lockstep. */\n const backoff = (attempt: number) =>\n new Promise(resolve => setTimeout(resolve, DDL_RETRY_BASE_MS * attempt * (1 + Math.random())));\n\n const step: DdlBootstrapper[\"step\"] = async (label, run) => {\n try {\n await run();\n } catch (err) {\n logger.error(`[${scope}] ${label} failed`, { error: err });\n }\n };\n\n return {\n step,\n\n ensureObject(label, sqlText) {\n return step(label, async () => {\n for (let attempt = 1; ; attempt++) {\n try {\n await exec(sqlText);\n return;\n } catch (err) {\n if (!isConcurrentDdlRace(err) || attempt >= DDL_ATTEMPTS) throw err;\n logger.debug(\n `[${scope}] Lost a create race for ${label} with another instance ` +\n `(attempt ${attempt}/${DDL_ATTEMPTS}) — retrying`\n );\n await backoff(attempt);\n }\n }\n });\n },\n\n async isReadable(table) {\n try {\n await exec(`SELECT 1 FROM ${table} WHERE false`);\n return true;\n } catch {\n return false;\n }\n }\n };\n}\n"],"mappings":";;;;;;;;;AAuiBA,SAAgB,WAAW,OAAqD;CAC5E,OAAO,CAAC,CAAC,SAAS,OAAQ,MAAmB,eAAe;AAChE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AClfA,IAAa,mBAAmB;;AAwChC,IAAM,kBAAkB;;;;;;;;;;;;;;;;;AAkBxB,SAAgB,uBAAuB,QAAgB,OAAuB;CAC1E,IAAI,CAAC,gBAAgB,KAAK,MAAM,GAC5B,MAAM,IAAI,MAAM,qDAAqD,KAAK,UAAU,MAAM,GAAG;CAEjG,IAAI,CAAC,gBAAgB,KAAK,KAAK,GAC3B,MAAM,IAAI,MAAM,oDAAoD,KAAK,UAAU,KAAK,GAAG;CAE/F,MAAM,YAAY,IAAI,OAAO,KAAK,MAAM;CACxC,OAAO;;;iEAGsD,iBAAiB;kCAChD,UAAU;yCACH,UAAU,QAAQ,iBAAiB;;;;MAItE,KAAK;AACX;;;;;;;;ACjGA,IAAa,2CAA2B,IAAI,IAAI;CAC5C;CACA;CACA;CACA;CACA;AACJ,CAAC;AAID,IAAM,oBAAoB;;;;;AAM1B,SAAgB,gBAAgB,KAAc,OAAyD;CACnG,IAAI,UAAmB;CACvB,KAAK,IAAI,QAAQ,GAAG,QAAQ,MAAM,SAAS,SAAS;EAChD,IAAI,OAAO,YAAY,UAAU;EACjC,MAAM,IAAI;EACV,IAAI,MAAM,CAAC,GAAG,OAAO;EACrB,UAAU,EAAE;CAChB;CACA,OAAO;AACX;;;;;;;;AASA,SAAgB,oBAAoB,KAAuB;CACvD,OAAO,gBAAgB,MAAM,MACxB,OAAO,EAAE,SAAS,YAAY,yBAAyB,IAAI,EAAE,IAAI,KAEjE,OAAO,EAAE,YAAY,YAAY,kBAAkB,KAAK,EAAE,OAAO,CACtE;AACJ;;;;;AA0BA,SAAgB,sBAAsB,MAAe,OAAgC;;CAEjF,MAAM,WAAW,YACb,IAAI,SAAQ,YAAW,WAAW,SAAS,oBAAoB,WAAW,IAAI,KAAK,OAAO,EAAE,CAAC;CAEjG,MAAM,OAAgC,OAAO,OAAO,QAAQ;EACxD,IAAI;GACA,MAAM,IAAI;EACd,SAAS,KAAK;GACV,OAAO,MAAM,IAAI,MAAM,IAAI,MAAM,UAAU,EAAE,OAAO,IAAI,CAAC;EAC7D;CACJ;CAEA,OAAO;EACH;EAEA,aAAa,OAAO,SAAS;GACzB,OAAO,KAAK,OAAO,YAAY;IAC3B,KAAK,IAAI,UAAU,IAAK,WACpB,IAAI;KACA,MAAM,KAAK,OAAO;KAClB;IACJ,SAAS,KAAK;KACV,IAAI,CAAC,oBAAoB,GAAG,KAAK,WAAA,GAAyB,MAAM;KAChE,OAAO,MACH,IAAI,MAAM,2BAA2B,MAAM,kCAC/B,QAAQ,eACxB;KACA,MAAM,QAAQ,OAAO;IACzB;GAER,CAAC;EACL;EAEA,MAAM,WAAW,OAAO;GACpB,IAAI;IACA,MAAM,KAAK,iBAAiB,MAAM,aAAa;IAC/C,OAAO;GACX,QAAQ;IACJ,OAAO;GACX;EACJ;CACJ;AACJ"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML escaping for email bodies.
|
|
3
|
+
*
|
|
4
|
+
* Every default template interpolates values the server does not control — a
|
|
5
|
+
* `displayName` chosen at registration, an `appName` from config, a link base a
|
|
6
|
+
* host set — into markup that is then mailed, signed by the sending domain, to
|
|
7
|
+
* an address the same request chose. Unescaped, that turns `POST /auth/register`
|
|
8
|
+
* into a way to deliver arbitrary HTML (a heading, an anchor) from a domain
|
|
9
|
+
* whose SPF and DKIM check out.
|
|
10
|
+
*
|
|
11
|
+
* The fix is deliberately not five escaping calls at five interpolation sites:
|
|
12
|
+
* a sixth template would simply forget. Templates are built with the {@link html}
|
|
13
|
+
* tag, which escapes *every* interpolated value by default, and markup that is
|
|
14
|
+
* meant to pass through verbatim — the static style strings, a nested fragment —
|
|
15
|
+
* must say so with {@link raw}. Forgetting `raw` produces visibly escaped text
|
|
16
|
+
* in a test; forgetting to escape is invisible until someone exploits it.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Markup that has already been vetted and must be interpolated verbatim.
|
|
20
|
+
*
|
|
21
|
+
* Only ever construct this from a string literal in this package's own source
|
|
22
|
+
* (or from the {@link html} tag, which produces one). Wrapping user input in
|
|
23
|
+
* `raw()` defeats the entire mechanism.
|
|
24
|
+
*/
|
|
25
|
+
export declare class RawHtml {
|
|
26
|
+
readonly value: string;
|
|
27
|
+
constructor(value: string);
|
|
28
|
+
toString(): string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Mark a static, author-controlled string as safe to interpolate unescaped.
|
|
32
|
+
*/
|
|
33
|
+
export declare function raw(markup: string): RawHtml;
|
|
34
|
+
/**
|
|
35
|
+
* Escape the five characters that can change the meaning of HTML text or of a
|
|
36
|
+
* quoted attribute value.
|
|
37
|
+
*
|
|
38
|
+
* `&` goes first: escaping it after the others would double-escape the entities
|
|
39
|
+
* they just produced. `'` is included because it is a legal attribute delimiter,
|
|
40
|
+
* and `"` because it is the one this file uses.
|
|
41
|
+
*/
|
|
42
|
+
export declare function escapeHtml(value: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* Tagged template for email markup. Every `${...}` is escaped unless it is
|
|
45
|
+
* {@link RawHtml}.
|
|
46
|
+
*
|
|
47
|
+
* Returns `RawHtml` so fragments nest without being escaped a second time:
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* const button = url ? html`<a href="${url}">Open</a>` : raw("");
|
|
51
|
+
* const body = html`<div>${button}</div>`;
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export declare function html(strings: TemplateStringsArray, ...values: unknown[]): RawHtml;
|
package/dist/email/index.d.ts
CHANGED
|
@@ -3,4 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export type { EmailService, EmailSendOptions, SMTPConfig, EmailConfig, PasswordResetTemplateFunction, EmailVerificationTemplateFunction, UserInvitationTemplateFunction, WelcomeEmailTemplateFunction, MagicLinkTemplateFunction } from "./types";
|
|
5
5
|
export { SMTPEmailService, createEmailService } from "./smtp-email-service";
|
|
6
|
+
export { html, raw, escapeHtml, RawHtml } from "./html";
|
|
7
|
+
export { resolveEmailLinkBase, assertEmailLinkBases } from "./link-base";
|
|
8
|
+
export type { EmailLinkKind } from "./link-base";
|
|
6
9
|
export { getPasswordResetTemplate, getEmailVerificationTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, getMagicLinkTemplate } from "./templates";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { EmailConfig } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Resolution — and boot-time validation — of the base URL every emailed link is
|
|
4
|
+
* built from.
|
|
5
|
+
*
|
|
6
|
+
* Each link kind used to read one config field and fall back to `""`, which
|
|
7
|
+
* produces `href="/verify-email?token=…"`. A mail client has no base document to
|
|
8
|
+
* resolve that against, so the link is inert: the route still answers
|
|
9
|
+
* `{ success: true }`, the token is still minted and stored, and nothing in the
|
|
10
|
+
* logs says anything is wrong. `verifyEmailUrl` in particular is set by no boot
|
|
11
|
+
* path, so that was the *default* behaviour of email verification.
|
|
12
|
+
*
|
|
13
|
+
* Two changes: the fallback chains live here rather than being re-spelled at
|
|
14
|
+
* each call site (only the magic-link route had one), and
|
|
15
|
+
* {@link assertEmailLinkBases} refuses at boot when no absolute base can be
|
|
16
|
+
* resolved. A configuration error that only ever shows up as "the link in the
|
|
17
|
+
* email does nothing" is worth a failed start.
|
|
18
|
+
*/
|
|
19
|
+
/** Which link a base URL is being resolved for. */
|
|
20
|
+
export type EmailLinkKind = "resetPassword" | "verifyEmail" | "magicLink";
|
|
21
|
+
/**
|
|
22
|
+
* The absolute base URL for a link kind, or `""` when the config has none.
|
|
23
|
+
*
|
|
24
|
+
* Callers append their path to the result. `""` is only reachable on a config
|
|
25
|
+
* that never went through {@link assertEmailLinkBases} — a hand-constructed
|
|
26
|
+
* `SMTPEmailService` in a test, say — and is kept rather than thrown so that a
|
|
27
|
+
* misconfiguration cannot turn a password-reset request into a 500 that
|
|
28
|
+
* distinguishes existing accounts from missing ones.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveEmailLinkBase(config: EmailConfig | undefined, kind: EmailLinkKind): string;
|
|
31
|
+
/**
|
|
32
|
+
* Throw when an email configuration cannot produce a followable link.
|
|
33
|
+
*
|
|
34
|
+
* Called from `createEmailService`, i.e. from every boot path that wires email
|
|
35
|
+
* up (the managed runtime, both driver bootstrappers, and any app that passes
|
|
36
|
+
* `auth.email`). A base that is set but relative is reported separately from one
|
|
37
|
+
* that is missing, because the two have different fixes.
|
|
38
|
+
*/
|
|
39
|
+
export declare function assertEmailLinkBases(config: EmailConfig): void;
|
|
@@ -25,6 +25,12 @@ export declare class SMTPEmailService implements EmailService {
|
|
|
25
25
|
verifyConnection(): Promise<boolean>;
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
|
-
* Create an email service from configuration
|
|
28
|
+
* Create an email service from configuration.
|
|
29
|
+
*
|
|
30
|
+
* This is the choke point every boot path goes through — the managed runtime,
|
|
31
|
+
* both driver bootstrappers, and any app that passes `auth.email` — so it is
|
|
32
|
+
* where the link bases are checked. A config that can send mail but cannot build
|
|
33
|
+
* an absolute link fails the start rather than delivering dead `href="/…"` links
|
|
34
|
+
* and reporting success; see `assertEmailLinkBases`.
|
|
29
35
|
*/
|
|
30
36
|
export declare function createEmailService(config: EmailConfig): EmailService;
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Default email templates for authentication emails
|
|
2
|
+
* Default email templates for authentication emails.
|
|
3
|
+
*
|
|
4
|
+
* Every HTML body is built with the `html` tag from `./html`, which escapes each
|
|
5
|
+
* interpolated value unless it is explicitly marked `raw`. `displayName` reaches
|
|
6
|
+
* these templates straight from the registration body, and the resulting mail is
|
|
7
|
+
* sent — signed by the sending domain — to an address the same anonymous request
|
|
8
|
+
* chose, so an unescaped interpolation here is a phishing primitive, not a
|
|
9
|
+
* rendering glitch. Escaping lives in the tag rather than at the call sites so a
|
|
10
|
+
* sixth template inherits it instead of having to remember it.
|
|
3
11
|
*/
|
|
4
12
|
interface TemplateUser {
|
|
5
13
|
email: string;
|
package/dist/email/types.d.ts
CHANGED
|
@@ -96,11 +96,16 @@ export interface EmailConfig {
|
|
|
96
96
|
/**
|
|
97
97
|
* Base URL for password reset links (e.g., "https://myapp.com")
|
|
98
98
|
* The reset link will be: {baseUrl}/reset-password?token={token}
|
|
99
|
+
*
|
|
100
|
+
* Must be absolute: mail clients have no base document to resolve a relative
|
|
101
|
+
* href against. `createEmailService` refuses to start when email is
|
|
102
|
+
* configured and no absolute base URL can be resolved.
|
|
99
103
|
*/
|
|
100
104
|
resetPasswordUrl?: string;
|
|
101
105
|
/**
|
|
102
106
|
* Base URL for email verification links (e.g., "https://myapp.com")
|
|
103
107
|
* The verification link will be: {baseUrl}/verify-email?token={token}
|
|
108
|
+
* Falls back to `resetPasswordUrl` if not set.
|
|
104
109
|
*/
|
|
105
110
|
verifyEmailUrl?: string;
|
|
106
111
|
/**
|
|
@@ -114,7 +119,12 @@ export interface EmailConfig {
|
|
|
114
119
|
*/
|
|
115
120
|
appName?: string;
|
|
116
121
|
/**
|
|
117
|
-
* Custom email templates (optional - defaults are provided)
|
|
122
|
+
* Custom email templates (optional - defaults are provided).
|
|
123
|
+
*
|
|
124
|
+
* A template receives `user.displayName` exactly as the account was
|
|
125
|
+
* registered with it — user input, markup and all. Build the HTML with the
|
|
126
|
+
* `html` tagged template exported from `@rebasepro/server`, which escapes
|
|
127
|
+
* every interpolation, rather than a plain template literal.
|
|
118
128
|
*/
|
|
119
129
|
templates?: {
|
|
120
130
|
passwordReset?: PasswordResetTemplateFunction;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "module";
|
|
2
2
|
import process from "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
|
-
import { t as logger } from "./logger-
|
|
4
|
+
import { t as logger } from "./logger-DfvF_8r-.js";
|
|
5
5
|
//#region src/api/errors.ts
|
|
6
6
|
/** Tracks whether we've already shown the doctor hint (once per process). */
|
|
7
7
|
var _schemaDriftHinted = false;
|
|
@@ -90,14 +90,6 @@ var ApiError = class ApiError extends Error {
|
|
|
90
90
|
}
|
|
91
91
|
};
|
|
92
92
|
/**
|
|
93
|
-
* Type guard for errors that carry optional API metadata (statusCode, code, details).
|
|
94
|
-
* Returns true for any Error instance — the optional properties are then
|
|
95
|
-
* checked via normal property access.
|
|
96
|
-
*/
|
|
97
|
-
function isRebaseApiError(error) {
|
|
98
|
-
return error instanceof Error;
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
93
|
* Hono error-handling middleware (`app.onError`).
|
|
102
94
|
* Converts any error into the canonical `{ error: { message, code } }` shape.
|
|
103
95
|
*/
|
|
@@ -221,6 +213,6 @@ function codeToStatus(code) {
|
|
|
221
213
|
}[code];
|
|
222
214
|
}
|
|
223
215
|
//#endregion
|
|
224
|
-
export { errorHandler as n,
|
|
216
|
+
export { errorHandler as n, ApiError as t };
|
|
225
217
|
|
|
226
|
-
//# sourceMappingURL=errors-
|
|
218
|
+
//# sourceMappingURL=errors-B1WZEdsK.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors-B1WZEdsK.js","names":[],"sources":["../src/api/errors.ts"],"sourcesContent":["import type { ErrorHandler } from \"hono\";\nimport type { ContentfulStatusCode } from \"hono/utils/http-status\";\nimport type { HonoEnv } from \"./types\";\nimport { logger } from \"../utils/logger\";\n\n/** Tracks whether we've already shown the doctor hint (once per process). */\nlet _schemaDriftHinted = false;\n\n/** Shape of Postgres / network errors with diagnostic codes */\ninterface PgLikeError {\n code?: string;\n address?: string;\n port?: number;\n message?: string;\n table?: string;\n column?: string;\n schema?: string;\n detail?: string;\n hint?: string;\n constraint?: string;\n}\n\n/** 5-character SQLSTATE, e.g. `42501`, `23505`. */\nconst SQLSTATE_RE = /^[0-9A-Z]{5}$/;\n\n/**\n * Walk the cause chain for the underlying database error, identified by a\n * 5-char SQLSTATE `code`. Drizzle wraps the pg error in `.cause`, and route\n * code sometimes wraps drizzle again, so the real error may sit several\n * levels down.\n */\nfunction extractDbError(error: unknown, depth = 0): PgLikeError | null {\n if (!error || typeof error !== \"object\" || depth > 8) return null;\n const e = error as PgLikeError & { cause?: unknown };\n if (typeof e.code === \"string\" && SQLSTATE_RE.test(e.code)) return e;\n if (e.cause && typeof e.cause === \"object\") return extractDbError(e.cause, depth + 1);\n return null;\n}\n\n/**\n * Extract the missing table or column name from a PG error.\n * PG 42P01 messages look like: 'relation \"my_table\" does not exist'\n * PG 42703 messages look like: 'column \"my_col\" does not exist' or 'column my_table.my_col does not exist'\n */\nfunction extractMissingIdentifier(pgMessage?: string): string | null {\n if (!pgMessage) return null;\n // Match quoted identifier: relation \"xxx\" / column \"xxx\"\n const quoted = pgMessage.match(/(?:relation|column|table)\\s+\"([^\"]+)\"/i);\n if (quoted) return quoted[1];\n // Match unquoted: column table.col does not exist\n const unquoted = pgMessage.match(/(?:relation|column|table)\\s+([\\w.]+)\\s+does not exist/i);\n if (unquoted) return unquoted[1];\n return null;\n}\n\n/**\n * Standardized API error class.\n * Throw this from any route handler — the errorHandler middleware\n * will format it into `{ error: { message, code, details? } }`.\n */\nexport class ApiError extends Error {\n public readonly statusCode: number;\n public readonly code: string;\n public readonly details?: unknown;\n /**\n * Whether this outcome is a routine part of normal operation rather than\n * something an operator should look at. Expected errors log at debug; every\n * other operational error logs at warn.\n *\n * The motivating case is `POST /auth/refresh` with no session: clients\n * refresh on page load before they know whether one exists, so every\n * anonymous page view is a 401 — correct, and not worth a warning line.\n */\n public readonly expected: boolean;\n\n constructor(statusCode: number, code: string, message: string, details?: unknown, expected = false) {\n super(message);\n this.name = \"ApiError\";\n this.statusCode = statusCode;\n this.code = code;\n this.details = details;\n this.expected = expected;\n }\n\n // ── Factory methods ──────────────────────────────────────────────\n\n static badRequest(message: string, code = \"BAD_REQUEST\", details?: unknown): ApiError {\n return new ApiError(400, code, message, details);\n }\n\n static unauthorized(message: string, code = \"UNAUTHORIZED\"): ApiError {\n return new ApiError(401, code, message);\n }\n\n /**\n * A 401 that is a normal outcome, not an incident — logged at debug.\n * See {@link ApiError.expected}.\n */\n static unauthenticated(message: string, code = \"UNAUTHORIZED\"): ApiError {\n return new ApiError(401, code, message, undefined, true);\n }\n\n static forbidden(message: string, code = \"FORBIDDEN\"): ApiError {\n return new ApiError(403, code, message);\n }\n\n static notFound(message: string, code = \"NOT_FOUND\"): ApiError {\n return new ApiError(404, code, message);\n }\n\n static conflict(message: string, code = \"CONFLICT\"): ApiError {\n return new ApiError(409, code, message);\n }\n\n static internal(message: string, code = \"INTERNAL_ERROR\"): ApiError {\n return new ApiError(500, code, message);\n }\n\n static serviceUnavailable(message: string, code = \"SERVICE_UNAVAILABLE\"): ApiError {\n return new ApiError(503, code, message);\n }\n}\n\n/**\n * Canonical error response shape:\n * `{ error: { message: string, code: string, details?: unknown } }`\n */\nexport interface ErrorResponse {\n error: {\n message: string;\n code: string;\n details?: unknown;\n /** Request correlation ID for tracing (echoes X-Request-ID). */\n requestId?: string;\n };\n}\n\n/**\n * General shape of errors that flow through the API error handler.\n * Extends Error with optional HTTP status, error code, and details.\n */\nexport interface RebaseApiError extends Error {\n statusCode?: number;\n code?: string;\n details?: unknown;\n}\n\n// `isRebaseApiError` was here. It read `return error instanceof Error`, so it\n// answered yes to every error while being named and used as though it\n// discriminated — the create and update handlers guarded a \"classify this as\n// BAD_REQUEST\" branch on it, and an unreachable database was therefore reported\n// to callers as a bad request. Deleted rather than repaired: the shape it\n// claimed to test is not decidable from an `Error`, and the layer that does\n// know — the driver, which holds the SQLSTATE — raises a real `ApiError`.\n\n/**\n * Hono error-handling middleware (`app.onError`).\n * Converts any error into the canonical `{ error: { message, code } }` shape.\n */\nexport const errorHandler: ErrorHandler<HonoEnv> = (err, c) => {\n // Typecast custom error properties\n const error: RebaseApiError = err;\n const reqId = typeof c.get === \"function\" ? c.get(\"requestId\") : undefined;\n\n if (error instanceof ApiError || error.name === \"ApiError\") {\n // Operational errors — log at warn, unless the error declares itself a\n // routine outcome (see ApiError.expected), which would otherwise put a\n // warning in the log for every anonymous page view.\n const expected = error instanceof ApiError && error.expected;\n const line = `[API] ${c.req.method} ${c.req.path} → ${error.statusCode} ${error.code}: ${error.message}` +\n (reqId ? ` [${reqId}]` : \"\");\n if (expected) {\n logger.debug(line);\n } else {\n logger.warn(`⚠️ ${line}`);\n }\n return c.json({\n error: {\n message: error.message,\n code: error.code || \"INTERNAL_ERROR\",\n ...(error.details !== undefined && { details: error.details }),\n ...(reqId && { requestId: reqId })\n }\n } satisfies ErrorResponse, (error.statusCode || 500) as ContentfulStatusCode);\n }\n\n const statusCode = error.statusCode || codeToStatus(error.code) || 500;\n let code = error.code || \"INTERNAL_ERROR\";\n\n // Handle DB connection and specific system errors for better logging\n let logMessage = error.message;\n\n // Resolve the actual cause — Node's net module wraps dual-stack failures\n // in an AggregateError whose inner errors carry the real address/port.\n let resolvedCause: PgLikeError | undefined;\n if (error.cause && typeof error.cause === \"object\" && error.cause !== null && \"code\" in error.cause) {\n const cause = error.cause as PgLikeError & { errors?: PgLikeError[] };\n if (cause.code === \"ECONNREFUSED\" && !cause.address && Array.isArray(cause.errors)) {\n // AggregateError — pick the first inner error that has address info\n resolvedCause = cause.errors.find(e => e.address) || cause;\n } else {\n resolvedCause = cause;\n }\n }\n\n // The real database error may sit several levels down the cause chain.\n // Losing it turns a precise failure (e.g. an RLS denial) into an opaque\n // \"Failed query: …\" 500 that is undiagnosable without direct DB access.\n const dbError = extractDbError(error);\n\n if (resolvedCause && (resolvedCause.code === \"ENETUNREACH\" || resolvedCause.code === \"ECONNREFUSED\")) {\n const cause = resolvedCause;\n if (cause.code === \"ENETUNREACH\") {\n logMessage = `Network unreachable. Cannot connect to database at ${cause.address}:${cause.port}.`;\n } else {\n logMessage = `Connection refused to database at ${cause.address}:${cause.port}. Is PostgreSQL running?`;\n }\n } else if (\"code\" in error && error.code === \"ENETUNREACH\") {\n const netErr = error as PgLikeError;\n logMessage = `Network unreachable. Cannot connect to service at ${netErr.address}:${netErr.port}.`;\n } else if (dbError && (dbError.code === \"42703\" || dbError.code === \"42P01\")) {\n code = \"SCHEMA_DRIFT\";\n const issue = dbError.code === \"42703\" ? \"column\" : \"table\";\n const identifier = dbError.table || dbError.column || extractMissingIdentifier(dbError.message) || \"unknown\";\n logMessage = `Schema drift: ${issue} \"${identifier}\" does not exist in the database. Run \\`pnpm db:push\\` to sync your schema, or \\`pnpm db:migrate\\` to apply pending migrations.`;\n } else if (dbError) {\n const parts = [`[PG ${dbError.code}] ${dbError.message}`];\n if (dbError.detail) parts.push(`Detail: ${dbError.detail}`);\n if (dbError.hint) parts.push(`Hint: ${dbError.hint}`);\n if (dbError.table) parts.push(`Table: ${dbError.table}`);\n if (dbError.column) parts.push(`Column: ${dbError.column}`);\n if (dbError.constraint) parts.push(`Constraint: ${dbError.constraint}`);\n if (dbError.code === \"42501\") {\n code = \"DB_PERMISSION_DENIED\";\n parts.push(\n \"The database rejected the statement for lack of privilege — usually a row-level \" +\n `security policy${dbError.table ? ` on \"${dbError.table}\"` : \"\"} denying this role, ` +\n \"or a stale FORCE ROW LEVEL SECURITY flag binding the owner connection.\"\n );\n }\n logMessage = parts.join(\". \");\n }\n\n const isDbSchemaMismatch = code === \"SCHEMA_DRIFT\";\n\n if (isDbSchemaMismatch) {\n // Database schema mismatch is logged as a warning instead of a fatal error\n logger.warn(\n `⚠️ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` +\n (reqId ? ` [${reqId}]` : \"\")\n );\n // In dev mode, show a one-time hint to run `rebase doctor`\n if (!_schemaDriftHinted && process.env.NODE_ENV !== \"production\") {\n _schemaDriftHinted = true;\n logger.warn([\n \"\",\n \"┌──────────────────────────────────────────────────────────────┐\",\n \"│ 💡 TIP: Run `rebase doctor` for full schema diagnostics │\",\n \"│ │\",\n \"│ Quick fixes (local dev, against DATABASE_URL): │\",\n \"│ pnpm db:push sync schema to database (dev) │\",\n \"│ pnpm db:migrate generate + apply migration (prod) │\",\n \"│ rebase doctor full 3-way drift report │\",\n \"│ │\",\n \"│ Managed cloud: the runtime applies schema + RLS at boot │\",\n \"│ (REBASE_MIGRATE_ON_BOOT); redeploy rather than db:push, │\",\n \"│ which cannot reach the tenant database. │\",\n \"└──────────────────────────────────────────────────────────────┘\",\n \"\"\n ].join(\"\\n\"));\n }\n } else {\n // Unexpected errors — log at error level\n logger.error(\n `❌ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` +\n (reqId ? ` [${reqId}]` : \"\")\n );\n }\n\n // Suppress the huge stack trace for known DB errors: it is noisy, and the\n // extracted [PG …] line above carries the signal. The SQL and the bound\n // params it used to leak are no longer this branch's problem — `logger`\n // strips Drizzle's `Failed query: … / params: …` wrapper out of every\n // message and stack it emits, so the fallbacks below (a connection dropped\n // mid-statement carries no SQLSTATE, so `dbError` is null and the stack is\n // logged) are covered too.\n const suppressStack = isDbSchemaMismatch || dbError !== null || (statusCode < 500 && code === \"BAD_REQUEST\");\n if (!suppressStack) {\n logger.error(String(error.stack || error));\n }\n\n // Sanitize the message for the client to prevent leaking sensitive details\n // like SQL queries or internal IP addresses.\n let clientMessage = \"An unexpected error occurred\";\n if (statusCode < 500 && error.message) {\n // If it's a 4xx error (e.g. from validation), it's generally safe to send the message\n clientMessage = error.message;\n } else if (error instanceof ApiError || error.name === \"ApiError\") {\n // We already handled ApiError above, but just in case\n clientMessage = error.message;\n } else if (code === \"SCHEMA_DRIFT\") {\n const pgErr = dbError || (error as PgLikeError);\n const issue = pgErr.code === \"42703\" ? \"column\" : \"table\";\n const identifier = pgErr.table || pgErr.column || extractMissingIdentifier(pgErr.message || error.message) || \"unknown\";\n clientMessage = `Schema drift: ${issue} \"${identifier}\" does not exist. Run \\`pnpm db:push\\` to sync your schema.`;\n } else if (code === \"DB_PERMISSION_DENIED\") {\n clientMessage = `Permission denied by the database${dbError?.table ? ` on \"${dbError.table}\"` : \"\"} (row-level security). Check the RLS policies for this table.`;\n } else if (code === \"INTERNAL_ERROR\") {\n clientMessage = \"Internal Server Error\";\n }\n\n // Database diagnostics for the envelope: the SQLSTATE is always safe to\n // return; message/detail/hint can reference schema internals, so only\n // outside production.\n const dbDetails = dbError ? {\n dbCode: dbError.code,\n ...(process.env.NODE_ENV !== \"production\" && {\n dbMessage: dbError.message,\n ...(dbError.detail && { detail: dbError.detail }),\n ...(dbError.hint && { hint: dbError.hint })\n })\n } : undefined;\n\n return c.json({\n error: {\n message: clientMessage,\n code,\n ...(error.details !== undefined\n ? { details: error.details }\n : dbDetails !== undefined ? { details: dbDetails } : {}),\n ...(reqId && { requestId: reqId })\n }\n } satisfies ErrorResponse, statusCode as ContentfulStatusCode);\n};\n\n/**\n * Map known error codes to HTTP status codes.\n */\nfunction codeToStatus(code?: string): number | undefined {\n if (!code) return undefined;\n const map: Record<string, number> = {\n BAD_REQUEST: 400,\n INVALID_INPUT: 400,\n WEAK_PASSWORD: 400,\n UNAUTHORIZED: 401,\n INVALID_CREDENTIALS: 401,\n INVALID_TOKEN: 401,\n FORBIDDEN: 403,\n NOT_FOUND: 404,\n CONFLICT: 409,\n EMAIL_EXISTS: 409,\n ROLE_EXISTS: 409,\n SCHEMA_DRIFT: 500,\n DB_PERMISSION_DENIED: 500,\n INTERNAL_ERROR: 500,\n NOT_CONFIGURED: 503,\n SERVICE_UNAVAILABLE: 503\n };\n return map[code];\n}\n\n\n"],"mappings":"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"}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "module";
|
|
2
|
+
import "process";
|
|
3
|
+
__createRequire(import.meta.url);
|
|
4
|
+
import { n as __exportAll } from "./rolldown-runtime-DSJWtz9O.js";
|
|
5
|
+
import { t as logger } from "./logger-DfvF_8r-.js";
|
|
6
|
+
import { t as nativeDynamicImport } from "./dynamic-import-Dvh-K5fl.js";
|
|
7
|
+
import * as fs$1 from "fs";
|
|
8
|
+
import * as path$1 from "path";
|
|
9
|
+
import { pathToFileURL } from "url";
|
|
10
|
+
//#region src/functions/function-loader.ts
|
|
11
|
+
var function_loader_exports = /* @__PURE__ */ __exportAll({
|
|
12
|
+
loadFunctionsFromDirectory: () => loadFunctionsFromDirectory,
|
|
13
|
+
loadFunctionsWithDiagnostics: () => loadFunctionsWithDiagnostics
|
|
14
|
+
});
|
|
15
|
+
/**
|
|
16
|
+
* Extensions the bundler compiles (or a developer might reasonably write) that
|
|
17
|
+
* this loader cannot import. `rebase build` globs `functions/**\/*.ts`, so the
|
|
18
|
+
* build's idea of "a function file" is strictly wider than the runtime's — the
|
|
19
|
+
* worst direction for a mismatch to go. Anything listed here is reported as a
|
|
20
|
+
* problem instead of vanishing; non-code files (`.md`, `.json`, `.txt`) stay
|
|
21
|
+
* silent, because a README next to your functions is not a mistake.
|
|
22
|
+
*/
|
|
23
|
+
var UNSUPPORTED_CODE_EXTENSIONS = [
|
|
24
|
+
".mts",
|
|
25
|
+
".cts",
|
|
26
|
+
".tsx",
|
|
27
|
+
".jsx",
|
|
28
|
+
".mjs",
|
|
29
|
+
".cjs"
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* Auto-discover Hono route files from a directory.
|
|
33
|
+
*
|
|
34
|
+
* Each file should default-export a Hono app (or router).
|
|
35
|
+
* The filename (without extension) becomes the mount path:
|
|
36
|
+
* `functions/send-invoice.ts` → mounted at `/send-invoice`
|
|
37
|
+
*
|
|
38
|
+
* This mirrors how `loadCollectionsFromDirectory` works for collections.
|
|
39
|
+
*
|
|
40
|
+
* Returns only what loaded. Use {@link loadFunctionsWithDiagnostics} when the
|
|
41
|
+
* caller also needs to report what did not.
|
|
42
|
+
*/
|
|
43
|
+
async function loadFunctionsFromDirectory(directory, importModule = nativeDynamicImport) {
|
|
44
|
+
return (await loadFunctionsWithDiagnostics(directory, importModule)).functions;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* {@link loadFunctionsFromDirectory}, plus the list of files that were skipped.
|
|
48
|
+
*
|
|
49
|
+
* Never throws: a single malformed function must not crash server boot. The
|
|
50
|
+
* caller decides what to do with `problems` — `init.ts` mounts the router
|
|
51
|
+
* regardless and surfaces the count on the listing endpoint.
|
|
52
|
+
*/
|
|
53
|
+
async function loadFunctionsWithDiagnostics(directory, importModule = nativeDynamicImport) {
|
|
54
|
+
const functions = [];
|
|
55
|
+
const problems = [];
|
|
56
|
+
if (!fs$1.existsSync(directory)) return {
|
|
57
|
+
functions,
|
|
58
|
+
problems
|
|
59
|
+
};
|
|
60
|
+
const entries = fs$1.readdirSync(directory, { withFileTypes: true });
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const file = entry.name;
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
if (file.startsWith(".") || file === "node_modules") continue;
|
|
65
|
+
logger.warn(`[functions] ${file}/: subdirectory ignored. Functions are loaded from the top level of ${directory} only, so nothing under ${file}/ is served. Move the file up (or flatten the name: \`admin/users.ts\` → \`admin-users.ts\`).`);
|
|
66
|
+
problems.push(`${file}/ (subdirectory — functions are not loaded recursively)`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const extension = path$1.extname(file);
|
|
70
|
+
if (!file.startsWith(".") && !file.includes(".test.") && UNSUPPORTED_CODE_EXTENSIONS.includes(extension)) {
|
|
71
|
+
logger.warn(`[functions] ${file}: ${extension} files are not loaded. Rename it to .ts (or .js) to serve it.`);
|
|
72
|
+
problems.push(`${file} (unsupported extension ${extension})`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.startsWith(".") && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
|
|
76
|
+
const filePath = path$1.join(directory, file);
|
|
77
|
+
try {
|
|
78
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
79
|
+
const exported = (await importModule(fileUrl)).default;
|
|
80
|
+
if (!exported) {
|
|
81
|
+
logger.warn(`[functions] ${file}: no default export. Skipping.`);
|
|
82
|
+
problems.push(`${file} (no default export)`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (isHonoLike(exported)) {
|
|
86
|
+
const name = path$1.basename(file, path$1.extname(file));
|
|
87
|
+
functions.push({
|
|
88
|
+
name,
|
|
89
|
+
app: exported
|
|
90
|
+
});
|
|
91
|
+
logger.info(`⚡ Loaded function route: ${name}`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (typeof exported === "function") {
|
|
95
|
+
const result = exported();
|
|
96
|
+
if (isHonoLike(result)) {
|
|
97
|
+
const name = path$1.basename(file, path$1.extname(file));
|
|
98
|
+
functions.push({
|
|
99
|
+
name,
|
|
100
|
+
app: result
|
|
101
|
+
});
|
|
102
|
+
logger.info(`⚡ Loaded function route: ${name}`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const exportType = typeof exported;
|
|
107
|
+
const keys = exported && typeof exported === "object" ? Object.getOwnPropertyNames(Object.getPrototypeOf(exported)).slice(0, 10).join(", ") : "N/A";
|
|
108
|
+
logger.warn(`[functions] ${file}: default export is not a Hono app or factory. Skipping.\n export type: ${exportType}${exported?.constructor?.name ? ` (${exported.constructor.name})` : ""}\n prototype methods: ${keys}\n Hint: ensure the function exports a Hono app created with the same hono version as the server.
|
|
109
|
+
Author with \`defineFunction(...)\` from @rebasepro/server for a typed, checked contract.
|
|
110
|
+
The loader checks for .fetch() and .routes — any Hono-compatible app will work.`);
|
|
111
|
+
problems.push(`${file} (not a Hono app or factory)`);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
114
|
+
logger.error(`[functions] Failed to load ${file}: ${message}`);
|
|
115
|
+
problems.push(`${file} (threw: ${message})`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (problems.length > 0) logger.warn(`[functions] ${problems.length} function file(s) were skipped and will NOT be served:\n` + problems.map((p) => ` - ${p}`).join("\n") + "\n Fix these or author them with `defineFunction(...)` for a typed, compile-checked contract.");
|
|
120
|
+
return {
|
|
121
|
+
functions,
|
|
122
|
+
problems
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Duck-type check for Hono apps.
|
|
127
|
+
* We avoid `instanceof Hono` because different Hono versions
|
|
128
|
+
* installed in the user's project vs. our dependencies will
|
|
129
|
+
* not share the same prototype, causing false negatives.
|
|
130
|
+
*/
|
|
131
|
+
function isHonoLike(obj) {
|
|
132
|
+
if (!obj || typeof obj !== "object") return false;
|
|
133
|
+
const record = obj;
|
|
134
|
+
return typeof record.fetch === "function" && Array.isArray(record.routes);
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
export { loadFunctionsFromDirectory as n, function_loader_exports as t };
|
|
138
|
+
|
|
139
|
+
//# sourceMappingURL=function-loader-D1SwtCa5.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"function-loader-D1SwtCa5.js","names":[],"sources":["../src/functions/function-loader.ts"],"sourcesContent":["import * as fs from \"fs\";\nimport * as path from \"path\";\nimport { pathToFileURL } from \"url\";\nimport { Hono } from \"hono\";\nimport { logger } from \"../utils/logger.js\";\nimport { nativeDynamicImport, type ModuleImporter } from \"../utils/dynamic-import.js\";\n\nexport interface LoadedFunction {\n /** Endpoint name derived from filename (e.g., \"send-invoice\") */\n name: string;\n /** The Hono sub-app to mount */\n app: Hono<import(\"hono\").Env>;\n}\n\n/** What a directory of function files produced: what mounted, and what did not. */\nexport interface LoadedFunctions {\n /** The functions that will be served. */\n functions: LoadedFunction[];\n /**\n * One entry per file the loader saw and did **not** mount, each already\n * phrased as `<name> (<reason>)`. Returned rather than only logged so the\n * running server can say what is missing — a boot log line is not reachable\n * from `GET /api/functions`.\n */\n problems: string[];\n}\n\n/**\n * Extensions the bundler compiles (or a developer might reasonably write) that\n * this loader cannot import. `rebase build` globs `functions/**\\/*.ts`, so the\n * build's idea of \"a function file\" is strictly wider than the runtime's — the\n * worst direction for a mismatch to go. Anything listed here is reported as a\n * problem instead of vanishing; non-code files (`.md`, `.json`, `.txt`) stay\n * silent, because a README next to your functions is not a mistake.\n */\nconst UNSUPPORTED_CODE_EXTENSIONS = [\".mts\", \".cts\", \".tsx\", \".jsx\", \".mjs\", \".cjs\"];\n\n/**\n * Auto-discover Hono route files from a directory.\n *\n * Each file should default-export a Hono app (or router).\n * The filename (without extension) becomes the mount path:\n * `functions/send-invoice.ts` → mounted at `/send-invoice`\n *\n * This mirrors how `loadCollectionsFromDirectory` works for collections.\n *\n * Returns only what loaded. Use {@link loadFunctionsWithDiagnostics} when the\n * caller also needs to report what did not.\n */\nexport async function loadFunctionsFromDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedFunction[]> {\n return (await loadFunctionsWithDiagnostics(directory, importModule)).functions;\n}\n\n/**\n * {@link loadFunctionsFromDirectory}, plus the list of files that were skipped.\n *\n * Never throws: a single malformed function must not crash server boot. The\n * caller decides what to do with `problems` — `init.ts` mounts the router\n * regardless and surfaces the count on the listing endpoint.\n */\nexport async function loadFunctionsWithDiagnostics(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedFunctions> {\n const functions: LoadedFunction[] = [];\n // Aggregate problem files so a broken function surfaces as one loud\n // summary line, not just a warning buried per-file. We still don't throw:\n // a single malformed function must not crash server boot.\n const problems: string[] = [];\n\n if (!fs.existsSync(directory)) {\n return { functions, problems };\n }\n\n // `withFileTypes` so a directory entry is a *reported* skip rather than a\n // filter miss. `readdirSync(dir)` returned bare names, and a subdirectory\n // simply failed the `.ts`/`.js` test — so `functions/admin/users.ts` was\n // compiled by `rebase build`, shipped in the bundle, and then dropped at\n // boot without a single log line.\n const entries = fs.readdirSync(directory, { withFileTypes: true });\n for (const entry of entries) {\n const file = entry.name;\n\n if (entry.isDirectory()) {\n // Dot-directories are tooling (`.git`, `.turbo`), not intent.\n if (file.startsWith(\".\") || file === \"node_modules\") continue;\n logger.warn(\n `[functions] ${file}/: subdirectory ignored. Functions are loaded from the top level of ` +\n `${directory} only, so nothing under ${file}/ is served. Move the file up (or flatten the ` +\n \"name: `admin/users.ts` → `admin-users.ts`).\"\n );\n problems.push(`${file}/ (subdirectory — functions are not loaded recursively)`);\n continue;\n }\n\n const extension = path.extname(file);\n if (\n !file.startsWith(\".\") &&\n !file.includes(\".test.\") &&\n UNSUPPORTED_CODE_EXTENSIONS.includes(extension)\n ) {\n logger.warn(\n `[functions] ${file}: ${extension} files are not loaded. Rename it to .ts (or .js) to serve it.`\n );\n problems.push(`${file} (unsupported extension ${extension})`);\n continue;\n }\n\n if (\n (file.endsWith(\".ts\") || file.endsWith(\".js\")) &&\n // Dotfiles: notably macOS bsdtar AppleDouble sidecars (`._foo.ts`),\n // binary blobs that cannot be imported.\n !file.startsWith(\".\") &&\n !file.includes(\".test.\") &&\n !file.endsWith(\".d.ts\") &&\n file !== \"index.ts\" &&\n file !== \"index.js\"\n ) {\n const filePath = path.join(directory, file);\n try {\n const fileUrl = pathToFileURL(filePath).href;\n\n const mod = await importModule(fileUrl);\n\n const exported = mod.default;\n\n if (!exported) {\n logger.warn(`[functions] ${file}: no default export. Skipping.`);\n problems.push(`${file} (no default export)`);\n continue;\n }\n\n // Accept a Hono instance — use duck-typing to handle different\n // Hono versions which may not share the same prototype.\n if (isHonoLike(exported)) {\n const name = path.basename(file, path.extname(file));\n functions.push({ name,\napp: exported as Hono });\n logger.info(`⚡ Loaded function route: ${name}`);\n continue;\n }\n\n // Also accept a factory function that returns a Hono instance\n if (typeof exported === \"function\") {\n const result = exported();\n if (isHonoLike(result)) {\n const name = path.basename(file, path.extname(file));\n functions.push({ name,\napp: result as Hono });\n logger.info(`⚡ Loaded function route: ${name}`);\n continue;\n }\n }\n\n // Provide actionable diagnostics\n const exportType = typeof exported;\n const keys = exported && typeof exported === \"object\"\n ? Object.getOwnPropertyNames(Object.getPrototypeOf(exported)).slice(0, 10).join(\", \")\n : \"N/A\";\n logger.warn(\n `[functions] ${file}: default export is not a Hono app or factory. Skipping.\\n` +\n ` export type: ${exportType}${exported?.constructor?.name ? ` (${exported.constructor.name})` : \"\"}\\n` +\n ` prototype methods: ${keys}\\n` +\n \" Hint: ensure the function exports a Hono app created with the same hono version as the server.\\n\" +\n \" Author with `defineFunction(...)` from @rebasepro/server for a typed, checked contract.\\n\" +\n \" The loader checks for .fetch() and .routes — any Hono-compatible app will work.\"\n );\n problems.push(`${file} (not a Hono app or factory)`);\n } catch (err: unknown) {\n const message =\n err instanceof Error ? err.message : String(err);\n logger.error(`[functions] Failed to load ${file}: ${message}`);\n problems.push(`${file} (threw: ${message})`);\n }\n }\n }\n\n if (problems.length > 0) {\n logger.warn(\n `[functions] ${problems.length} function file(s) were skipped and will NOT be served:\\n` +\n problems.map((p) => ` - ${p}`).join(\"\\n\") + \"\\n\" +\n \" Fix these or author them with `defineFunction(...)` for a typed, compile-checked contract.\"\n );\n }\n\n return { functions, problems };\n}\n\n/**\n * Duck-type check for Hono apps.\n * We avoid `instanceof Hono` because different Hono versions\n * installed in the user's project vs. our dependencies will\n * not share the same prototype, causing false negatives.\n */\nfunction isHonoLike(obj: unknown): boolean {\n if (!obj || typeof obj !== \"object\") return false;\n // Hono instances always have .fetch() and .routes\n const record = obj as Record<string, unknown>;\n return (\n typeof record.fetch === \"function\" &&\n Array.isArray(record.routes)\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAmCA,IAAM,8BAA8B;CAAC;CAAQ;CAAQ;CAAQ;CAAQ;CAAQ;AAAM;;;;;;;;;;;;;AAcnF,eAAsB,2BAClB,WACA,eAA+B,qBACN;CACzB,QAAQ,MAAM,6BAA6B,WAAW,YAAY,EAAA,CAAG;AACzE;;;;;;;;AASA,eAAsB,6BAClB,WACA,eAA+B,qBACP;CACxB,MAAM,YAA8B,CAAC;CAIrC,MAAM,WAAqB,CAAC;CAE5B,IAAI,CAAC,KAAG,WAAW,SAAS,GACxB,OAAO;EAAE;EAAW;CAAS;CAQjC,MAAM,UAAU,KAAG,YAAY,WAAW,EAAE,eAAe,KAAK,CAAC;CACjE,KAAK,MAAM,SAAS,SAAS;EACzB,MAAM,OAAO,MAAM;EAEnB,IAAI,MAAM,YAAY,GAAG;GAErB,IAAI,KAAK,WAAW,GAAG,KAAK,SAAS,gBAAgB;GACrD,OAAO,KACH,eAAe,KAAK,sEACjB,UAAU,0BAA0B,KAAK,8FAEhD;GACA,SAAS,KAAK,GAAG,KAAK,wDAAwD;GAC9E;EACJ;EAEA,MAAM,YAAY,OAAK,QAAQ,IAAI;EACnC,IACI,CAAC,KAAK,WAAW,GAAG,KACpB,CAAC,KAAK,SAAS,QAAQ,KACvB,4BAA4B,SAAS,SAAS,GAChD;GACE,OAAO,KACH,eAAe,KAAK,IAAI,UAAU,8DACtC;GACA,SAAS,KAAK,GAAG,KAAK,0BAA0B,UAAU,EAAE;GAC5D;EACJ;EAEA,KACK,KAAK,SAAS,KAAK,KAAK,KAAK,SAAS,KAAK,MAG5C,CAAC,KAAK,WAAW,GAAG,KACpB,CAAC,KAAK,SAAS,QAAQ,KACvB,CAAC,KAAK,SAAS,OAAO,KACtB,SAAS,cACT,SAAS,YACX;GACE,MAAM,WAAW,OAAK,KAAK,WAAW,IAAI;GAC1C,IAAI;IACA,MAAM,UAAU,cAAc,QAAQ,CAAC,CAAC;IAIxC,MAAM,YAAW,MAFC,aAAa,OAAO,EAAA,CAEjB;IAErB,IAAI,CAAC,UAAU;KACX,OAAO,KAAK,eAAe,KAAK,+BAA+B;KAC/D,SAAS,KAAK,GAAG,KAAK,qBAAqB;KAC3C;IACJ;IAIA,IAAI,WAAW,QAAQ,GAAG;KACtB,MAAM,OAAO,OAAK,SAAS,MAAM,OAAK,QAAQ,IAAI,CAAC;KACnD,UAAU,KAAK;MAAE;MACrC,KAAK;KAAiB,CAAC;KACH,OAAO,KAAK,4BAA4B,MAAM;KAC9C;IACJ;IAGA,IAAI,OAAO,aAAa,YAAY;KAChC,MAAM,SAAS,SAAS;KACxB,IAAI,WAAW,MAAM,GAAG;MACpB,MAAM,OAAO,OAAK,SAAS,MAAM,OAAK,QAAQ,IAAI,CAAC;MACnD,UAAU,KAAK;OAAE;OACzC,KAAK;MAAe,CAAC;MACG,OAAO,KAAK,4BAA4B,MAAM;MAC9C;KACJ;IACJ;IAGA,MAAM,aAAa,OAAO;IAC1B,MAAM,OAAO,YAAY,OAAO,aAAa,WACvC,OAAO,oBAAoB,OAAO,eAAe,QAAQ,CAAC,CAAC,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC,KAAK,IAAI,IAClF;IACN,OAAO,KACH,eAAe,KAAK,2EACF,aAAa,UAAU,aAAa,OAAO,KAAK,SAAS,YAAY,KAAK,KAAK,GAAG,yBAC5E,KAAK;;kFAIjC;IACA,SAAS,KAAK,GAAG,KAAK,6BAA6B;GACvD,SAAS,KAAc;IACnB,MAAM,UACF,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;IACnD,OAAO,MAAM,8BAA8B,KAAK,IAAI,SAAS;IAC7D,SAAS,KAAK,GAAG,KAAK,WAAW,QAAQ,EAAE;GAC/C;EACJ;CACJ;CAEA,IAAI,SAAS,SAAS,GAClB,OAAO,KACH,eAAe,SAAS,OAAO,4DAC/B,SAAS,KAAK,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,IAAI,IAAI,gGAEjD;CAGJ,OAAO;EAAE;EAAW;CAAS;AACjC;;;;;;;AAQA,SAAS,WAAW,KAAuB;CACvC,IAAI,CAAC,OAAO,OAAO,QAAQ,UAAU,OAAO;CAE5C,MAAM,SAAS;CACf,OACI,OAAO,OAAO,UAAU,cACxB,MAAM,QAAQ,OAAO,MAAM;AAEnC"}
|
|
@@ -10,14 +10,26 @@ var function_routes_exports = /* @__PURE__ */ __exportAll({ createFunctionRoutes
|
|
|
10
10
|
*
|
|
11
11
|
* Each function is mounted at `/<function-name>`, preserving
|
|
12
12
|
* whatever HTTP methods and middleware the Hono sub-app defines.
|
|
13
|
+
*
|
|
14
|
+
* @param functions What loaded. May be empty — the router still mounts, so
|
|
15
|
+
* "no functions are served" answers 200 with an empty list instead of 404.
|
|
16
|
+
* @param skipped How many files the loader saw and could not serve. Reported
|
|
17
|
+
* as a count and a pointer to the log, not as filenames: the listing is
|
|
18
|
+
* reachable anonymously, and the per-file reasons carry import errors.
|
|
13
19
|
*/
|
|
14
|
-
function createFunctionRoutes(functions) {
|
|
20
|
+
function createFunctionRoutes(functions, skipped = 0) {
|
|
15
21
|
const router = new Hono();
|
|
16
22
|
router.get("/", (c) => {
|
|
17
|
-
return c.json({
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
return c.json({
|
|
24
|
+
functions: functions.map((fn) => ({
|
|
25
|
+
name: fn.name,
|
|
26
|
+
endpoint: `/functions/${fn.name}`
|
|
27
|
+
})),
|
|
28
|
+
...skipped > 0 && {
|
|
29
|
+
skipped,
|
|
30
|
+
note: `${skipped} function file(s) failed to load and are NOT served — see the server log for the reason.`
|
|
31
|
+
}
|
|
32
|
+
});
|
|
21
33
|
});
|
|
22
34
|
for (const fn of functions) router.route(`/${fn.name}`, fn.app);
|
|
23
35
|
return router;
|
|
@@ -25,4 +37,4 @@ function createFunctionRoutes(functions) {
|
|
|
25
37
|
//#endregion
|
|
26
38
|
export { function_routes_exports as n, createFunctionRoutes as t };
|
|
27
39
|
|
|
28
|
-
//# sourceMappingURL=function-routes-
|
|
40
|
+
//# sourceMappingURL=function-routes-Btcez1T-.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"function-routes-Btcez1T-.js","names":[],"sources":["../src/functions/function-routes.ts"],"sourcesContent":["import { Hono } from \"hono\";\nimport { HonoEnv } from \"../api/types\";\nimport { LoadedFunction } from \"./function-loader\";\n\n/**\n * Mount all loaded function routes under a single Hono router.\n *\n * Each function is mounted at `/<function-name>`, preserving\n * whatever HTTP methods and middleware the Hono sub-app defines.\n *\n * @param functions What loaded. May be empty — the router still mounts, so\n * \"no functions are served\" answers 200 with an empty list instead of 404.\n * @param skipped How many files the loader saw and could not serve. Reported\n * as a count and a pointer to the log, not as filenames: the listing is\n * reachable anonymously, and the per-file reasons carry import errors.\n */\nexport function createFunctionRoutes(\n functions: LoadedFunction[],\n skipped = 0\n): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n\n // Listing endpoint: GET / → list available functions\n router.get(\"/\", (c) => {\n return c.json({\n functions: functions.map((fn) => ({\n name: fn.name,\n endpoint: `/functions/${fn.name}`\n })),\n ...(skipped > 0 && {\n skipped,\n note: `${skipped} function file(s) failed to load and are NOT served — see the server log for the reason.`\n })\n });\n });\n\n for (const fn of functions) {\n router.route(`/${fn.name}`, fn.app);\n }\n\n return router;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAgBA,SAAgB,qBACZ,WACA,UAAU,GACG;CACb,MAAM,SAAS,IAAI,KAAc;CAGjC,OAAO,IAAI,MAAM,MAAM;EACnB,OAAO,EAAE,KAAK;GACV,WAAW,UAAU,KAAK,QAAQ;IAC9B,MAAM,GAAG;IACT,UAAU,cAAc,GAAG;GAC/B,EAAE;GACF,GAAI,UAAU,KAAK;IACf;IACA,MAAM,GAAG,QAAQ;GACrB;EACJ,CAAC;CACL,CAAC;CAED,KAAK,MAAM,MAAM,WACb,OAAO,MAAM,IAAI,GAAG,QAAQ,GAAG,GAAG;CAGtC,OAAO;AACX"}
|
|
@@ -15,11 +15,27 @@ export interface RebaseFunctionContext {
|
|
|
15
15
|
* The server-side Rebase singleton (`dataAsAdmin`, `auth`, `storage`,
|
|
16
16
|
* `email`, `sql`).
|
|
17
17
|
*
|
|
18
|
-
* `rebase.dataAsAdmin` runs
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* `rebase.dataAsAdmin` runs as the service identity
|
|
19
|
+
* `{ uid: "service", roles: ["admin"] }` — **admin-scoped, not an RLS
|
|
20
|
+
* bypass**. Policies are still evaluated; it passes the default ones
|
|
21
|
+
* through their `rolesOverlap(['admin'])` arm, the same arm an application
|
|
22
|
+
* user holding the `admin` role passes. Two things follow:
|
|
23
|
+
*
|
|
24
|
+
* - `policy.serverContext()` (`auth.uid() IS NULL`) is **false** here. A
|
|
25
|
+
* collection with `disableDefaultPolicies: true` whose write rule is
|
|
26
|
+
* `serverContext()` will refuse these writes with `42501`, and reads
|
|
27
|
+
* against a hand-written admin policy that does not name the `admin` role
|
|
28
|
+
* return zero rows with a 200.
|
|
29
|
+
* - Do not read it as "nobody else can reach these rows". Whatever an
|
|
30
|
+
* `admin`-roled user can reach, this can, and vice versa.
|
|
31
|
+
*
|
|
32
|
+
* `rebase.sql()` is the true bypass: it runs on the owner connection and
|
|
33
|
+
* never goes through `withAuth`.
|
|
34
|
+
*
|
|
35
|
+
* For user-scoped queries inside a handler, use the request `driver`
|
|
36
|
+
* (`c.var.driver`), which carries the caller's identity. (`rebase.data` no
|
|
37
|
+
* longer exists on this type — `dataAsAdmin` is the only name for the
|
|
38
|
+
* admin-scoped accessor.)
|
|
23
39
|
*/
|
|
24
40
|
rebase: RebaseServerClient;
|
|
25
41
|
}
|
|
@@ -41,7 +57,10 @@ export interface RebaseFunctionContext {
|
|
|
41
57
|
* export default defineFunction((app, { rebase }) => {
|
|
42
58
|
* app.use("/*", requireAuth);
|
|
43
59
|
* app.get("/home", async (c) => {
|
|
44
|
-
*
|
|
60
|
+
* // `rebase.sql` runs on the owner connection: no RLS, no policies,
|
|
61
|
+
* // every row. It is the most privileged thing in this context —
|
|
62
|
+
* // more so than `dataAsAdmin`, which is merely admin-scoped.
|
|
63
|
+
* const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
|
|
45
64
|
* return c.json({ orders: Number(stats.n) });
|
|
46
65
|
* });
|
|
47
66
|
* });
|