diff --git a/CHANGELOG.md b/CHANGELOG.md index 68800fb19..3d4c30097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **ObjectSchema `namespace` property** — New optional `namespace` field on `ObjectSchema` for logical domain + classification (e.g., `'sys'`, `'crm'`). When set, `tableName` is auto-derived as `{namespace}_{name}` by + `ObjectSchema.create()` unless an explicit `tableName` is provided. This decouples the logical object name + from the physical table name and enables unified routing, permissions, and discovery by domain. +- **SystemObjectName constants** — Extended with all system objects: `ORGANIZATION`, `MEMBER`, `INVITATION`, + `TEAM`, `TEAM_MEMBER`, `API_KEY`, `TWO_FACTOR`, `ROLE`, `PERMISSION_SET`, `AUDIT_LOG` (in addition to + existing `USER`, `SESSION`, `ACCOUNT`, `VERIFICATION`, `METADATA`). +- **plugin-auth system objects** — Added `SysOrganization`, `SysMember`, `SysInvitation`, `SysTeam`, + `SysTeamMember`, `SysApiKey`, `SysTwoFactor` object definitions with `namespace: 'sys'`. Existing objects + (`SysUser`, `SysSession`, `SysAccount`, `SysVerification`) migrated to use namespace convention. +- **plugin-security system objects** — Added `SysRole` and `SysPermissionSet` object definitions. +- **plugin-audit** — New plugin package with `SysAuditLog` immutable audit trail object definition. +- **StorageNameMapping.resolveTableName()** — Now supports namespace-aware auto-derivation + (`{namespace}_{name}` fallback when no explicit `tableName` is set). + +### Changed +- **System object naming convention** — All system objects now use `namespace: 'sys'` with short `name` + (e.g., `name: 'user'` instead of `name: 'sys_user'`). The `sys_` prefix is auto-derived via + `tableName` = `{namespace}_{name}`. File naming follows `sys-{name}.object.ts` pattern. +- **plugin-auth object exports** — New canonical exports use `Sys*` prefix (e.g., `SysUser`, `SysSession`). + Legacy `Auth*` exports are preserved as deprecated re-exports for backward compatibility. +- **sys_metadata object** — Migrated to `namespace: 'sys'`, `name: 'metadata'` convention (tableName + auto-derived as `sys_metadata`). - **Locale code fallback** — New `resolveLocale()` helper in `@objectstack/core` that resolves locale codes through a 4-step fallback chain: exact match → case-insensitive match (`zh-cn` → `zh-CN`) → base language match (`zh-CN` → `zh`) → variant expansion diff --git a/ROADMAP.md b/ROADMAP.md index c6cf89da8..b936e70bf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -307,24 +307,50 @@ The following renames are planned for packages that implement core service contr ### System Object Naming Convention (`sys_` Prefix) > **Adopted:** 2026-02-19 +> **Updated:** 2026-03-11 — Namespace-based architecture with auto-derivation > **Scope:** All system kernel objects in `SystemObjectName` constants. -All system kernel objects use the `sys_` prefix to clearly distinguish platform-internal objects from +All system kernel objects use the `sys` namespace to clearly distinguish platform-internal objects from business/custom objects, aligning with industry best practices (e.g., ServiceNow `sys_user`, `sys_audit`). -| Constant Key | Protocol Name | Description | -|:---|:---|:---| -| `SystemObjectName.USER` | `sys_user` | Authentication: user identity | -| `SystemObjectName.SESSION` | `sys_session` | Authentication: active session | -| `SystemObjectName.ACCOUNT` | `sys_account` | Authentication: OAuth / credential account | -| `SystemObjectName.VERIFICATION` | `sys_verification` | Authentication: email / phone verification | -| `SystemObjectName.METADATA` | `sys_metadata` | System metadata storage | +Objects now declare `namespace: 'sys'` and a short `name` (e.g., `name: 'user'`). The physical table name +`sys_user` is auto-derived as `{namespace}_{name}` by `ObjectSchema.create()`. + +| Constant Key | Protocol Name | Plugin | Description | +|:---|:---|:---|:---| +| `SystemObjectName.USER` | `sys_user` | plugin-auth | Authentication: user identity | +| `SystemObjectName.SESSION` | `sys_session` | plugin-auth | Authentication: active session | +| `SystemObjectName.ACCOUNT` | `sys_account` | plugin-auth | Authentication: OAuth / credential account | +| `SystemObjectName.VERIFICATION` | `sys_verification` | plugin-auth | Authentication: email / phone verification | +| `SystemObjectName.ORGANIZATION` | `sys_organization` | plugin-auth | Authentication: organization (multi-org) | +| `SystemObjectName.MEMBER` | `sys_member` | plugin-auth | Authentication: organization member | +| `SystemObjectName.INVITATION` | `sys_invitation` | plugin-auth | Authentication: organization invitation | +| `SystemObjectName.TEAM` | `sys_team` | plugin-auth | Authentication: team within an organization | +| `SystemObjectName.TEAM_MEMBER` | `sys_team_member` | plugin-auth | Authentication: team membership | +| `SystemObjectName.API_KEY` | `sys_api_key` | plugin-auth | Authentication: API key for programmatic access | +| `SystemObjectName.TWO_FACTOR` | `sys_two_factor` | plugin-auth | Authentication: two-factor credentials | +| `SystemObjectName.ROLE` | `sys_role` | plugin-security | Security: RBAC role definition | +| `SystemObjectName.PERMISSION_SET` | `sys_permission_set` | plugin-security | Security: permission set grouping | +| `SystemObjectName.AUDIT_LOG` | `sys_audit_log` | plugin-audit | Audit: immutable audit trail | +| `SystemObjectName.METADATA` | `sys_metadata` | metadata | System metadata storage | + +**Object Definition Convention:** +- File naming: `sys-{name}.object.ts` (e.g., `sys-user.object.ts`, `sys-role.object.ts`) +- Export naming: `Sys{PascalCase}` (e.g., `SysUser`, `SysRole`, `SysAuditLog`) +- Object schema: `namespace: 'sys'`, `name: '{short_name}'` (no `sys_` prefix in name) +- Table derivation: `tableName` auto-derived as `sys_{name}` unless explicitly overridden **Rationale:** - Prevents naming collisions between system objects and business objects (e.g., a CRM `account` vs. `sys_account`) - Aligns with ServiceNow and similar platforms that use `sys_` as a reserved namespace - ObjectStack already uses namespace + FQN for business object isolation; the `sys_` prefix completes the picture for kernel-level objects - Physical storage table names can differ via `ObjectSchema.tableName` + `StorageNameMapping.resolveTableName()` for backward compatibility +- Namespace-based auto-derivation eliminates manual `tableName` boilerplate and ensures consistency + +**Plugin Architecture:** +- Each plugin (plugin-auth, plugin-security, plugin-audit) owns and registers its own `sys` namespace objects +- Plugins remain decoupled and optional — consumers aggregate all `sys` objects at runtime +- Object definitions follow the ObjectSchema protocol with `isSystem: true` **Migration (v3.x → v4.0):** - v3.x: The `SystemObjectName` constants now emit `sys_`-prefixed names. Implementations using `StorageNameMapping.resolveTableName()` can set `tableName` to preserve legacy physical table names during the transition. @@ -333,7 +359,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow - v3.x: **Bug fix** — `AuthManager.createDatabaseConfig()` now wraps the ObjectQL adapter as a `DBAdapterInstance` factory function (`(options) => DBAdapter`). Previously the raw adapter object was passed, which fell through to the Kysely adapter path and failed silently. `AuthManager.handleRequest()` and `AuthPlugin.registerAuthRoutes()` now inspect `response.status >= 500` and log the error body, since better-auth catches internal errors and returns 500 Responses without throwing. - v3.x: **Bug fix** — `AuthPlugin` now defers HTTP route registration to a `kernel:ready` hook instead of doing it synchronously in `start()`. This makes the plugin resilient to plugin loading order — the `http-server` service is guaranteed to be available after all plugins complete their init/start phases. The CLI `serve` command also registers `HonoServerPlugin` before config plugins (with duplicate detection) for the same reason. - v3.x: **Bug fix** — Studio `useApiDiscovery` hook no longer hardcodes auth endpoints as `/api/auth/...`. The `discover()` callback now fetches `/api/v1/discovery` and reads `routes.auth` to dynamically construct auth endpoint paths (falling back to `/api/v1/auth`). The session endpoint is corrected from `/session` to `/get-session` to align with better-auth's `AuthEndpointPaths.getSession`. -- v4.0: Legacy un-prefixed aliases will be fully removed. +- v4.0: Legacy un-prefixed aliases and `Auth*` export names will be fully removed. --- diff --git a/packages/metadata/src/objects/sys-metadata.object.ts b/packages/metadata/src/objects/sys-metadata.object.ts index 90e583292..3678e4631 100644 --- a/packages/metadata/src/objects/sys-metadata.object.ts +++ b/packages/metadata/src/objects/sys-metadata.object.ts @@ -15,7 +15,8 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * @see MetadataRecordSchema in metadata-persistence.zod.ts */ export const SysMetadataObject = ObjectSchema.create({ - name: 'sys_metadata', + namespace: 'sys', + name: 'metadata', label: 'System Metadata', pluralLabel: 'System Metadata', icon: 'settings', diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json new file mode 100644 index 000000000..d5e9d1923 --- /dev/null +++ b/packages/plugins/plugin-audit/package.json @@ -0,0 +1,27 @@ +{ + "name": "@objectstack/plugin-audit", + "version": "3.2.5", + "license": "Apache-2.0", + "description": "Audit Plugin for ObjectStack — System audit log object and audit trail", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts", + "test": "vitest run" + }, + "dependencies": { + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.3.5", + "typescript": "^5.0.0", + "vitest": "^4.0.18" + } +} diff --git a/packages/plugins/plugin-audit/src/index.ts b/packages/plugins/plugin-audit/src/index.ts new file mode 100644 index 000000000..0241b2b67 --- /dev/null +++ b/packages/plugins/plugin-audit/src/index.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/plugin-audit + * + * Audit Plugin for ObjectStack + * Provides the sys_audit_log system object definition for immutable audit trails. + */ + +// System Object Definitions (sys namespace) +export { SysAuditLog } from './objects/index.js'; diff --git a/packages/plugins/plugin-audit/src/objects/index.ts b/packages/plugins/plugin-audit/src/objects/index.ts new file mode 100644 index 000000000..40cee1a97 --- /dev/null +++ b/packages/plugins/plugin-audit/src/objects/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Audit Plugin — System Object Definitions (sys namespace) + * + * Canonical ObjectSchema definitions for audit-related system objects. + */ + +export { SysAuditLog } from './sys-audit-log.object.js'; diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts new file mode 100644 index 000000000..597d94f7a --- /dev/null +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_audit_log — System Audit Log Object + * + * Immutable audit trail for all significant platform events. + * Records who did what, when, and the before/after state. + * + * @namespace sys + */ +export const SysAuditLog = ObjectSchema.create({ + namespace: 'sys', + name: 'audit_log', + label: 'Audit Log', + pluralLabel: 'Audit Logs', + icon: 'scroll-text', + isSystem: true, + description: 'Immutable audit trail for platform events', + titleFormat: '{action} on {object_name} by {user_id}', + compactLayout: ['action', 'object_name', 'user_id', 'created_at'], + + fields: { + id: Field.text({ + label: 'Audit Log ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Timestamp', + required: true, + defaultValue: 'NOW()', + readonly: true, + }), + + user_id: Field.text({ + label: 'User ID', + required: false, + description: 'User who performed the action (null for system actions)', + }), + + action: Field.select(['create', 'update', 'delete', 'restore', 'login', 'logout', 'permission_change', 'config_change', 'export', 'import'], { + label: 'Action', + required: true, + description: 'Action type (snake_case). Values: create, update, delete, restore, login, logout, permission_change, config_change, export, import', + }), + + object_name: Field.text({ + label: 'Object Name', + required: false, + maxLength: 255, + description: 'Target object (e.g. sys_user, project_task)', + }), + + record_id: Field.text({ + label: 'Record ID', + required: false, + description: 'ID of the affected record', + }), + + old_value: Field.textarea({ + label: 'Old Value', + required: false, + description: 'JSON-serialized previous state', + }), + + new_value: Field.textarea({ + label: 'New Value', + required: false, + description: 'JSON-serialized new state', + }), + + ip_address: Field.text({ + label: 'IP Address', + required: false, + maxLength: 45, + }), + + user_agent: Field.textarea({ + label: 'User Agent', + required: false, + }), + + tenant_id: Field.text({ + label: 'Tenant ID', + required: false, + description: 'Tenant context for multi-tenant isolation', + }), + + metadata: Field.textarea({ + label: 'Metadata', + required: false, + description: 'JSON-serialized additional context', + }), + }, + + indexes: [ + { fields: ['created_at'] }, + { fields: ['user_id'] }, + { fields: ['object_name', 'record_id'] }, + { fields: ['action'] }, + { fields: ['tenant_id'] }, + ], + + enable: { + trackHistory: false, // Audit logs are themselves the audit trail + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list'], // Read-only — audit logs are immutable; creation happens via internal system hooks only + trash: false, // Never soft-delete audit logs + mru: false, + clone: false, + }, +}); diff --git a/packages/plugins/plugin-audit/tsconfig.json b/packages/plugins/plugin-audit/tsconfig.json new file mode 100644 index 000000000..ead733427 --- /dev/null +++ b/packages/plugins/plugin-audit/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/packages/plugins/plugin-auth/src/auth-schema-config.ts b/packages/plugins/plugin-auth/src/auth-schema-config.ts index ca99f7387..13d54d5b8 100644 --- a/packages/plugins/plugin-auth/src/auth-schema-config.ts +++ b/packages/plugins/plugin-auth/src/auth-schema-config.ts @@ -157,7 +157,7 @@ export const AUTH_VERIFICATION_CONFIG = { * | updatedAt | updated_at | */ export const AUTH_ORGANIZATION_SCHEMA = { - modelName: 'sys_organization', + modelName: SystemObjectName.ORGANIZATION, // 'sys_organization' fields: { createdAt: 'created_at', updatedAt: 'updated_at', @@ -178,7 +178,7 @@ export const AUTH_ORGANIZATION_SCHEMA = { * | createdAt | created_at | */ export const AUTH_MEMBER_SCHEMA = { - modelName: 'sys_member', + modelName: SystemObjectName.MEMBER, // 'sys_member' fields: { organizationId: 'organization_id', userId: 'user_id', @@ -202,7 +202,7 @@ export const AUTH_MEMBER_SCHEMA = { * | teamId | team_id | */ export const AUTH_INVITATION_SCHEMA = { - modelName: 'sys_invitation', + modelName: SystemObjectName.INVITATION, // 'sys_invitation' fields: { organizationId: 'organization_id', inviterId: 'inviter_id', @@ -240,7 +240,7 @@ export const AUTH_ORG_SESSION_FIELDS = { * | updatedAt | updated_at | */ export const AUTH_TEAM_SCHEMA = { - modelName: 'sys_team', + modelName: SystemObjectName.TEAM, // 'sys_team' fields: { organizationId: 'organization_id', createdAt: 'created_at', @@ -262,7 +262,7 @@ export const AUTH_TEAM_SCHEMA = { * | createdAt | created_at | */ export const AUTH_TEAM_MEMBER_SCHEMA = { - modelName: 'sys_team_member', + modelName: SystemObjectName.TEAM_MEMBER, // 'sys_team_member' fields: { teamId: 'team_id', userId: 'user_id', @@ -283,7 +283,7 @@ export const AUTH_TEAM_MEMBER_SCHEMA = { * | userId | user_id | */ export const AUTH_TWO_FACTOR_SCHEMA = { - modelName: 'sys_two_factor', + modelName: SystemObjectName.TWO_FACTOR, // 'sys_two_factor' fields: { backupCodes: 'backup_codes', userId: 'user_id', diff --git a/packages/plugins/plugin-auth/src/objects/auth-account.object.ts b/packages/plugins/plugin-auth/src/objects/auth-account.object.ts index 5d1ac9d4a..2531bf0cd 100644 --- a/packages/plugins/plugin-auth/src/objects/auth-account.object.ts +++ b/packages/plugins/plugin-auth/src/objects/auth-account.object.ts @@ -1,121 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ObjectSchema, Field } from '@objectstack/spec/data'; - /** - * Auth Account Object - * - * Uses better-auth's native schema for seamless migration: - * - id: string - * - created_at: Date - * - updated_at: Date - * - provider_id: string (e.g., 'google', 'github') - * - account_id: string (provider's user ID) - * - user_id: string (link to user table) - * - access_token: string | null - * - refresh_token: string | null - * - id_token: string | null - * - access_token_expires_at: Date | null - * - refresh_token_expires_at: Date | null - * - scope: string | null - * - password: string | null (for email/password provider) + * @deprecated Use `SysAccount` from `./sys-account.object` instead. + * This re-export is kept for backward compatibility. */ -export const AuthAccount = ObjectSchema.create({ - name: 'sys_account', - label: 'Account', - pluralLabel: 'Accounts', - icon: 'link', - description: 'OAuth and authentication provider accounts', - titleFormat: '{provider_id} - {account_id}', - compactLayout: ['provider_id', 'user_id', 'account_id'], - - fields: { - id: Field.text({ - label: 'Account ID', - required: true, - readonly: true, - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - }), - - provider_id: Field.text({ - label: 'Provider ID', - required: true, - description: 'OAuth provider identifier (google, github, etc.)', - }), - - account_id: Field.text({ - label: 'Provider Account ID', - required: true, - description: "User's ID in the provider's system", - }), - - user_id: Field.text({ - label: 'User ID', - required: true, - description: 'Link to user table', - }), - - access_token: Field.textarea({ - label: 'Access Token', - required: false, - }), - - refresh_token: Field.textarea({ - label: 'Refresh Token', - required: false, - }), - - id_token: Field.textarea({ - label: 'ID Token', - required: false, - }), - - access_token_expires_at: Field.datetime({ - label: 'Access Token Expires At', - required: false, - }), - - refresh_token_expires_at: Field.datetime({ - label: 'Refresh Token Expires At', - required: false, - }), - - scope: Field.text({ - label: 'OAuth Scope', - required: false, - }), - - password: Field.text({ - label: 'Password Hash', - required: false, - description: 'Hashed password for email/password provider', - }), - }, - - // Database indexes for performance - indexes: [ - { fields: ['user_id'], unique: false }, - { fields: ['provider_id', 'account_id'], unique: true }, - ], - - // Enable features - enable: { - trackHistory: false, - searchable: false, - apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'update', 'delete'], - trash: true, - mru: false, - }, -}); +export { SysAccount as AuthAccount } from './sys-account.object.js'; diff --git a/packages/plugins/plugin-auth/src/objects/auth-session.object.ts b/packages/plugins/plugin-auth/src/objects/auth-session.object.ts index b72402f96..3f375f61a 100644 --- a/packages/plugins/plugin-auth/src/objects/auth-session.object.ts +++ b/packages/plugins/plugin-auth/src/objects/auth-session.object.ts @@ -1,89 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ObjectSchema, Field } from '@objectstack/spec/data'; - /** - * Auth Session Object - * - * Uses better-auth's native schema for seamless migration: - * - id: string - * - created_at: Date - * - updated_at: Date - * - user_id: string - * - expires_at: Date - * - token: string - * - ip_address: string | null - * - user_agent: string | null + * @deprecated Use `SysSession` from `./sys-session.object` instead. + * This re-export is kept for backward compatibility. */ -export const AuthSession = ObjectSchema.create({ - name: 'sys_session', - label: 'Session', - pluralLabel: 'Sessions', - icon: 'key', - description: 'Active user sessions', - titleFormat: 'Session {token}', - compactLayout: ['user_id', 'expires_at', 'ip_address'], - - fields: { - id: Field.text({ - label: 'Session ID', - required: true, - readonly: true, - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - }), - - user_id: Field.text({ - label: 'User ID', - required: true, - }), - - expires_at: Field.datetime({ - label: 'Expires At', - required: true, - }), - - token: Field.text({ - label: 'Session Token', - required: true, - }), - - ip_address: Field.text({ - label: 'IP Address', - required: false, - maxLength: 45, // Support IPv6 - }), - - user_agent: Field.textarea({ - label: 'User Agent', - required: false, - }), - }, - - // Database indexes for performance - indexes: [ - { fields: ['token'], unique: true }, - { fields: ['user_id'], unique: false }, - { fields: ['expires_at'], unique: false }, - ], - - // Enable features - enable: { - trackHistory: false, // Sessions don't need history tracking - searchable: false, - apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'delete'], // No update for sessions - trash: false, // Sessions should be hard deleted - mru: false, - }, -}); +export { SysSession as AuthSession } from './sys-session.object.js'; diff --git a/packages/plugins/plugin-auth/src/objects/auth-user.object.ts b/packages/plugins/plugin-auth/src/objects/auth-user.object.ts index b04c1601d..5df40bfef 100644 --- a/packages/plugins/plugin-auth/src/objects/auth-user.object.ts +++ b/packages/plugins/plugin-auth/src/objects/auth-user.object.ts @@ -1,97 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ObjectSchema, Field } from '@objectstack/spec/data'; - /** - * Auth User Object - * - * Uses better-auth's native schema for seamless migration: - * - id: string - * - created_at: Date - * - updated_at: Date - * - email: string (unique, lowercase) - * - email_verified: boolean - * - name: string - * - image: string | null + * @deprecated Use `SysUser` from `./sys-user.object` instead. + * This re-export is kept for backward compatibility. */ -export const AuthUser = ObjectSchema.create({ - name: 'sys_user', - label: 'User', - pluralLabel: 'Users', - icon: 'user', - description: 'User accounts for authentication', - titleFormat: '{name} ({email})', - compactLayout: ['name', 'email', 'email_verified'], - - fields: { - // ID is auto-generated by ObjectQL - id: Field.text({ - label: 'User ID', - required: true, - readonly: true, - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - }), - - email: Field.email({ - label: 'Email', - required: true, - searchable: true, - }), - - email_verified: Field.boolean({ - label: 'Email Verified', - defaultValue: false, - }), - - name: Field.text({ - label: 'Name', - required: true, - searchable: true, - maxLength: 255, - }), - - image: Field.url({ - label: 'Profile Image', - required: false, - }), - }, - - // Database indexes for performance - indexes: [ - { fields: ['email'], unique: true }, - { fields: ['created_at'], unique: false }, - ], - - // Enable features - enable: { - trackHistory: true, - searchable: true, - apiEnabled: true, - apiMethods: ['get', 'list', 'create', 'update', 'delete'], - trash: true, - mru: true, - }, - - // Validation Rules - validations: [ - { - name: 'email_unique', - type: 'unique', - severity: 'error', - message: 'Email must be unique', - fields: ['email'], - caseSensitive: false, - }, - ], -}); +export { SysUser as AuthUser } from './sys-user.object.js'; diff --git a/packages/plugins/plugin-auth/src/objects/auth-verification.object.ts b/packages/plugins/plugin-auth/src/objects/auth-verification.object.ts index 6618745b2..5411a6d70 100644 --- a/packages/plugins/plugin-auth/src/objects/auth-verification.object.ts +++ b/packages/plugins/plugin-auth/src/objects/auth-verification.object.ts @@ -1,78 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ObjectSchema, Field } from '@objectstack/spec/data'; - /** - * Auth Verification Object - * - * Uses better-auth's native schema for seamless migration: - * - id: string - * - created_at: Date - * - updated_at: Date - * - value: string (verification token/code) - * - expires_at: Date - * - identifier: string (email or phone number) + * @deprecated Use `SysVerification` from `./sys-verification.object` instead. + * This re-export is kept for backward compatibility. */ -export const AuthVerification = ObjectSchema.create({ - name: 'sys_verification', - label: 'Verification', - pluralLabel: 'Verifications', - icon: 'shield-check', - description: 'Email and phone verification tokens', - titleFormat: 'Verification for {identifier}', - compactLayout: ['identifier', 'expires_at', 'created_at'], - - fields: { - id: Field.text({ - label: 'Verification ID', - required: true, - readonly: true, - }), - - created_at: Field.datetime({ - label: 'Created At', - defaultValue: 'NOW()', - readonly: true, - }), - - updated_at: Field.datetime({ - label: 'Updated At', - defaultValue: 'NOW()', - readonly: true, - }), - - value: Field.text({ - label: 'Verification Token', - required: true, - description: 'Token or code for verification', - }), - - expires_at: Field.datetime({ - label: 'Expires At', - required: true, - }), - - identifier: Field.text({ - label: 'Identifier', - required: true, - description: 'Email address or phone number', - }), - }, - - // Database indexes for performance - indexes: [ - { fields: ['value'], unique: true }, - { fields: ['identifier'], unique: false }, - { fields: ['expires_at'], unique: false }, - ], - - // Enable features - enable: { - trackHistory: false, - searchable: false, - apiEnabled: true, - apiMethods: ['get', 'create', 'delete'], // No list or update - trash: false, // Hard delete expired tokens - mru: false, - }, -}); +export { SysVerification as AuthVerification } from './sys-verification.object.js'; diff --git a/packages/plugins/plugin-auth/src/objects/index.ts b/packages/plugins/plugin-auth/src/objects/index.ts index 273f36b9b..b84cb452c 100644 --- a/packages/plugins/plugin-auth/src/objects/index.ts +++ b/packages/plugins/plugin-auth/src/objects/index.ts @@ -1,13 +1,39 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * Auth Objects - * - * ObjectQL-based object definitions for authentication database schema. - * These objects replace the need for third-party ORMs like drizzle-orm. + * Auth Plugin — System Object Definitions (sys namespace) + * + * Canonical ObjectSchema definitions for all authentication-related system objects. + * All objects belong to the `sys` namespace and follow the unified naming convention: + * - File: `sys-{name}.object.ts` + * - Export: `Sys{PascalCase}` + * - Object name: `{name}` (snake_case, no prefix) + * - Table name: `sys_{name}` (auto-derived from namespace) */ +// ── Core Auth Objects ────────────────────────────────────────────────────── +export { SysUser } from './sys-user.object.js'; +export { SysSession } from './sys-session.object.js'; +export { SysAccount } from './sys-account.object.js'; +export { SysVerification } from './sys-verification.object.js'; + +// ── Organization Objects ─────────────────────────────────────────────────── +export { SysOrganization } from './sys-organization.object.js'; +export { SysMember } from './sys-member.object.js'; +export { SysInvitation } from './sys-invitation.object.js'; +export { SysTeam } from './sys-team.object.js'; +export { SysTeamMember } from './sys-team-member.object.js'; + +// ── Additional Auth Objects ──────────────────────────────────────────────── +export { SysApiKey } from './sys-api-key.object.js'; +export { SysTwoFactor } from './sys-two-factor.object.js'; + +// ── Backward Compatibility (deprecated) ──────────────────────────────────── +/** @deprecated Use `SysUser` instead */ export { AuthUser } from './auth-user.object.js'; +/** @deprecated Use `SysSession` instead */ export { AuthSession } from './auth-session.object.js'; +/** @deprecated Use `SysAccount` instead */ export { AuthAccount } from './auth-account.object.js'; +/** @deprecated Use `SysVerification` instead */ export { AuthVerification } from './auth-verification.object.js'; diff --git a/packages/plugins/plugin-auth/src/objects/sys-account.object.ts b/packages/plugins/plugin-auth/src/objects/sys-account.object.ts new file mode 100644 index 000000000..c6cc423e3 --- /dev/null +++ b/packages/plugins/plugin-auth/src/objects/sys-account.object.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_account — System Account Object + * + * OAuth / credential provider account record. + * Backed by better-auth's `account` model with ObjectStack field conventions. + * + * @namespace sys + */ +export const SysAccount = ObjectSchema.create({ + namespace: 'sys', + name: 'account', + label: 'Account', + pluralLabel: 'Accounts', + icon: 'link', + isSystem: true, + description: 'OAuth and authentication provider accounts', + titleFormat: '{provider_id} - {account_id}', + compactLayout: ['provider_id', 'user_id', 'account_id'], + + fields: { + id: Field.text({ + label: 'Account ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + provider_id: Field.text({ + label: 'Provider ID', + required: true, + description: 'OAuth provider identifier (google, github, etc.)', + }), + + account_id: Field.text({ + label: 'Provider Account ID', + required: true, + description: "User's ID in the provider's system", + }), + + user_id: Field.text({ + label: 'User ID', + required: true, + description: 'Link to user table', + }), + + access_token: Field.textarea({ + label: 'Access Token', + required: false, + }), + + refresh_token: Field.textarea({ + label: 'Refresh Token', + required: false, + }), + + id_token: Field.textarea({ + label: 'ID Token', + required: false, + }), + + access_token_expires_at: Field.datetime({ + label: 'Access Token Expires At', + required: false, + }), + + refresh_token_expires_at: Field.datetime({ + label: 'Refresh Token Expires At', + required: false, + }), + + scope: Field.text({ + label: 'OAuth Scope', + required: false, + }), + + password: Field.text({ + label: 'Password Hash', + required: false, + description: 'Hashed password for email/password provider', + }), + }, + + indexes: [ + { fields: ['user_id'], unique: false }, + { fields: ['provider_id', 'account_id'], unique: true }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: true, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-auth/src/objects/sys-api-key.object.ts b/packages/plugins/plugin-auth/src/objects/sys-api-key.object.ts new file mode 100644 index 000000000..1e59f100b --- /dev/null +++ b/packages/plugins/plugin-auth/src/objects/sys-api-key.object.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_api_key — System API Key Object + * + * API keys for programmatic/machine access to the platform. + * + * @namespace sys + */ +export const SysApiKey = ObjectSchema.create({ + namespace: 'sys', + name: 'api_key', + label: 'API Key', + pluralLabel: 'API Keys', + icon: 'key-round', + isSystem: true, + description: 'API keys for programmatic access', + titleFormat: '{name}', + compactLayout: ['name', 'user_id', 'expires_at'], + + fields: { + id: Field.text({ + label: 'API Key ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + name: Field.text({ + label: 'Name', + required: true, + maxLength: 255, + description: 'Human-readable label for the API key', + }), + + key: Field.text({ + label: 'Key', + required: true, + description: 'Hashed API key value', + }), + + prefix: Field.text({ + label: 'Prefix', + required: false, + maxLength: 16, + description: 'Visible prefix for identifying the key (e.g., "osk_")', + }), + + user_id: Field.text({ + label: 'User ID', + required: true, + description: 'Owner user of this API key', + }), + + scopes: Field.textarea({ + label: 'Scopes', + required: false, + description: 'JSON array of permission scopes', + }), + + expires_at: Field.datetime({ + label: 'Expires At', + required: false, + }), + + last_used_at: Field.datetime({ + label: 'Last Used At', + required: false, + }), + + revoked: Field.boolean({ + label: 'Revoked', + defaultValue: false, + }), + }, + + indexes: [ + { fields: ['key'], unique: true }, + { fields: ['user_id'] }, + { fields: ['prefix'] }, + ], + + enable: { + trackHistory: true, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: false, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-auth/src/objects/sys-invitation.object.ts b/packages/plugins/plugin-auth/src/objects/sys-invitation.object.ts new file mode 100644 index 000000000..4698c4230 --- /dev/null +++ b/packages/plugins/plugin-auth/src/objects/sys-invitation.object.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_invitation — System Invitation Object + * + * Organization invitation tokens for inviting users. + * Backed by better-auth's organization plugin. + * + * @namespace sys + */ +export const SysInvitation = ObjectSchema.create({ + namespace: 'sys', + name: 'invitation', + label: 'Invitation', + pluralLabel: 'Invitations', + icon: 'mail', + isSystem: true, + description: 'Organization invitations for user onboarding', + titleFormat: 'Invitation to {organization_id}', + compactLayout: ['email', 'organization_id', 'status'], + + fields: { + id: Field.text({ + label: 'Invitation ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + organization_id: Field.text({ + label: 'Organization ID', + required: true, + }), + + email: Field.email({ + label: 'Email', + required: true, + description: 'Email address of the invited user', + }), + + role: Field.text({ + label: 'Role', + required: false, + maxLength: 100, + description: 'Role to assign upon acceptance', + }), + + status: Field.select(['pending', 'accepted', 'rejected', 'expired', 'canceled'], { + label: 'Status', + required: true, + defaultValue: 'pending', + }), + + inviter_id: Field.text({ + label: 'Inviter ID', + required: true, + description: 'User ID of the person who sent the invitation', + }), + + expires_at: Field.datetime({ + label: 'Expires At', + required: true, + }), + + team_id: Field.text({ + label: 'Team ID', + required: false, + description: 'Optional team to assign upon acceptance', + }), + }, + + indexes: [ + { fields: ['organization_id'] }, + { fields: ['email'] }, + { fields: ['expires_at'] }, + ], + + enable: { + trackHistory: true, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: false, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-auth/src/objects/sys-member.object.ts b/packages/plugins/plugin-auth/src/objects/sys-member.object.ts new file mode 100644 index 000000000..28d400c76 --- /dev/null +++ b/packages/plugins/plugin-auth/src/objects/sys-member.object.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_member — System Member Object + * + * Organization membership linking users to organizations with roles. + * Backed by better-auth's organization plugin. + * + * @namespace sys + */ +export const SysMember = ObjectSchema.create({ + namespace: 'sys', + name: 'member', + label: 'Member', + pluralLabel: 'Members', + icon: 'user-check', + isSystem: true, + description: 'Organization membership records', + titleFormat: '{user_id} in {organization_id}', + compactLayout: ['user_id', 'organization_id', 'role'], + + fields: { + id: Field.text({ + label: 'Member ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + organization_id: Field.text({ + label: 'Organization ID', + required: true, + }), + + user_id: Field.text({ + label: 'User ID', + required: true, + }), + + role: Field.text({ + label: 'Role', + required: false, + description: 'Member role within the organization (e.g. admin, member)', + maxLength: 100, + }), + }, + + indexes: [ + { fields: ['organization_id', 'user_id'], unique: true }, + { fields: ['user_id'] }, + ], + + enable: { + trackHistory: true, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: false, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-auth/src/objects/sys-organization.object.ts b/packages/plugins/plugin-auth/src/objects/sys-organization.object.ts new file mode 100644 index 000000000..14599fc46 --- /dev/null +++ b/packages/plugins/plugin-auth/src/objects/sys-organization.object.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_organization — System Organization Object + * + * Multi-organization support for the ObjectStack platform. + * Backed by better-auth's organization plugin. + * + * @namespace sys + */ +export const SysOrganization = ObjectSchema.create({ + namespace: 'sys', + name: 'organization', + label: 'Organization', + pluralLabel: 'Organizations', + icon: 'building-2', + isSystem: true, + description: 'Organizations for multi-tenant grouping', + titleFormat: '{name}', + compactLayout: ['name', 'slug', 'created_at'], + + fields: { + id: Field.text({ + label: 'Organization ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + name: Field.text({ + label: 'Name', + required: true, + searchable: true, + maxLength: 255, + }), + + slug: Field.text({ + label: 'Slug', + required: false, + maxLength: 255, + description: 'URL-friendly identifier', + }), + + logo: Field.url({ + label: 'Logo', + required: false, + }), + + metadata: Field.textarea({ + label: 'Metadata', + required: false, + description: 'JSON-serialized organization metadata', + }), + }, + + indexes: [ + { fields: ['slug'], unique: true }, + { fields: ['name'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: true, + mru: true, + }, +}); diff --git a/packages/plugins/plugin-auth/src/objects/sys-session.object.ts b/packages/plugins/plugin-auth/src/objects/sys-session.object.ts new file mode 100644 index 000000000..0d0e14243 --- /dev/null +++ b/packages/plugins/plugin-auth/src/objects/sys-session.object.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_session — System Session Object + * + * Active user session record for the ObjectStack platform. + * Backed by better-auth's `session` model with ObjectStack field conventions. + * + * @namespace sys + */ +export const SysSession = ObjectSchema.create({ + namespace: 'sys', + name: 'session', + label: 'Session', + pluralLabel: 'Sessions', + icon: 'key', + isSystem: true, + description: 'Active user sessions', + titleFormat: 'Session {token}', + compactLayout: ['user_id', 'expires_at', 'ip_address'], + + fields: { + id: Field.text({ + label: 'Session ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + user_id: Field.text({ + label: 'User ID', + required: true, + }), + + expires_at: Field.datetime({ + label: 'Expires At', + required: true, + }), + + token: Field.text({ + label: 'Session Token', + required: true, + }), + + ip_address: Field.text({ + label: 'IP Address', + required: false, + maxLength: 45, // Support IPv6 + }), + + user_agent: Field.textarea({ + label: 'User Agent', + required: false, + }), + }, + + indexes: [ + { fields: ['token'], unique: true }, + { fields: ['user_id'], unique: false }, + { fields: ['expires_at'], unique: false }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'delete'], + trash: false, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-auth/src/objects/sys-team-member.object.ts b/packages/plugins/plugin-auth/src/objects/sys-team-member.object.ts new file mode 100644 index 000000000..ed98de21c --- /dev/null +++ b/packages/plugins/plugin-auth/src/objects/sys-team-member.object.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_team_member — System Team Member Object + * + * Links users to teams within organizations. + * Backed by better-auth's organization plugin (teams feature). + * + * @namespace sys + */ +export const SysTeamMember = ObjectSchema.create({ + namespace: 'sys', + name: 'team_member', + label: 'Team Member', + pluralLabel: 'Team Members', + icon: 'user-plus', + isSystem: true, + description: 'Team membership records linking users to teams', + titleFormat: '{user_id} in {team_id}', + compactLayout: ['user_id', 'team_id', 'created_at'], + + fields: { + id: Field.text({ + label: 'Team Member ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + team_id: Field.text({ + label: 'Team ID', + required: true, + }), + + user_id: Field.text({ + label: 'User ID', + required: true, + }), + }, + + indexes: [ + { fields: ['team_id', 'user_id'], unique: true }, + { fields: ['user_id'] }, + ], + + enable: { + trackHistory: true, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'delete'], + trash: false, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-auth/src/objects/sys-team.object.ts b/packages/plugins/plugin-auth/src/objects/sys-team.object.ts new file mode 100644 index 000000000..efeded841 --- /dev/null +++ b/packages/plugins/plugin-auth/src/objects/sys-team.object.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_team — System Team Object + * + * Teams within an organization for fine-grained grouping. + * Backed by better-auth's organization plugin (teams feature). + * + * @namespace sys + */ +export const SysTeam = ObjectSchema.create({ + namespace: 'sys', + name: 'team', + label: 'Team', + pluralLabel: 'Teams', + icon: 'users', + isSystem: true, + description: 'Teams within organizations for fine-grained grouping', + titleFormat: '{name}', + compactLayout: ['name', 'organization_id', 'created_at'], + + fields: { + id: Field.text({ + label: 'Team ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + name: Field.text({ + label: 'Name', + required: true, + searchable: true, + maxLength: 255, + }), + + organization_id: Field.text({ + label: 'Organization ID', + required: true, + }), + }, + + indexes: [ + { fields: ['organization_id'] }, + { fields: ['name', 'organization_id'], unique: true }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: true, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-auth/src/objects/sys-two-factor.object.ts b/packages/plugins/plugin-auth/src/objects/sys-two-factor.object.ts new file mode 100644 index 000000000..8900650e0 --- /dev/null +++ b/packages/plugins/plugin-auth/src/objects/sys-two-factor.object.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_two_factor — System Two-Factor Object + * + * Two-factor authentication credentials (TOTP, backup codes). + * Backed by better-auth's two-factor plugin. + * + * @namespace sys + */ +export const SysTwoFactor = ObjectSchema.create({ + namespace: 'sys', + name: 'two_factor', + label: 'Two Factor', + pluralLabel: 'Two Factor Credentials', + icon: 'smartphone', + isSystem: true, + description: 'Two-factor authentication credentials', + titleFormat: 'Two-factor for {user_id}', + compactLayout: ['user_id', 'created_at'], + + fields: { + id: Field.text({ + label: 'Two Factor ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + user_id: Field.text({ + label: 'User ID', + required: true, + }), + + secret: Field.text({ + label: 'Secret', + required: true, + description: 'TOTP secret key', + }), + + backup_codes: Field.textarea({ + label: 'Backup Codes', + required: false, + description: 'JSON-serialized backup recovery codes', + }), + }, + + indexes: [ + { fields: ['user_id'], unique: true }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'create', 'update', 'delete'], + trash: false, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-auth/src/objects/sys-user.object.ts b/packages/plugins/plugin-auth/src/objects/sys-user.object.ts new file mode 100644 index 000000000..62d64de39 --- /dev/null +++ b/packages/plugins/plugin-auth/src/objects/sys-user.object.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_user — System User Object + * + * Canonical user identity record for the ObjectStack platform. + * Backed by better-auth's `user` model with ObjectStack field conventions. + * + * @namespace sys + */ +export const SysUser = ObjectSchema.create({ + namespace: 'sys', + name: 'user', + label: 'User', + pluralLabel: 'Users', + icon: 'user', + isSystem: true, + description: 'User accounts for authentication', + titleFormat: '{name} ({email})', + compactLayout: ['name', 'email', 'email_verified'], + + fields: { + id: Field.text({ + label: 'User ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + email: Field.email({ + label: 'Email', + required: true, + searchable: true, + }), + + email_verified: Field.boolean({ + label: 'Email Verified', + defaultValue: false, + }), + + name: Field.text({ + label: 'Name', + required: true, + searchable: true, + maxLength: 255, + }), + + image: Field.url({ + label: 'Profile Image', + required: false, + }), + }, + + indexes: [ + { fields: ['email'], unique: true }, + { fields: ['created_at'], unique: false }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: true, + mru: true, + }, + + validations: [ + { + name: 'email_unique', + type: 'unique', + severity: 'error', + message: 'Email must be unique', + fields: ['email'], + caseSensitive: false, + }, + ], +}); diff --git a/packages/plugins/plugin-auth/src/objects/sys-verification.object.ts b/packages/plugins/plugin-auth/src/objects/sys-verification.object.ts new file mode 100644 index 000000000..b785b600e --- /dev/null +++ b/packages/plugins/plugin-auth/src/objects/sys-verification.object.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_verification — System Verification Object + * + * Email and phone verification token record. + * Backed by better-auth's `verification` model with ObjectStack field conventions. + * + * @namespace sys + */ +export const SysVerification = ObjectSchema.create({ + namespace: 'sys', + name: 'verification', + label: 'Verification', + pluralLabel: 'Verifications', + icon: 'shield-check', + isSystem: true, + description: 'Email and phone verification tokens', + titleFormat: 'Verification for {identifier}', + compactLayout: ['identifier', 'expires_at', 'created_at'], + + fields: { + id: Field.text({ + label: 'Verification ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + value: Field.text({ + label: 'Verification Token', + required: true, + description: 'Token or code for verification', + }), + + expires_at: Field.datetime({ + label: 'Expires At', + required: true, + }), + + identifier: Field.text({ + label: 'Identifier', + required: true, + description: 'Email address or phone number', + }), + }, + + indexes: [ + { fields: ['value'], unique: true }, + { fields: ['identifier'], unique: false }, + { fields: ['expires_at'], unique: false }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + apiMethods: ['get', 'create', 'delete'], + trash: false, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index cca27a626..244dcd06a 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -11,3 +11,6 @@ export { SecurityPlugin } from './security-plugin.js'; export { PermissionEvaluator } from './permission-evaluator.js'; export { RLSCompiler } from './rls-compiler.js'; export { FieldMasker } from './field-masker.js'; + +// System Object Definitions (sys namespace) +export { SysRole, SysPermissionSet } from './objects/index.js'; diff --git a/packages/plugins/plugin-security/src/objects/index.ts b/packages/plugins/plugin-security/src/objects/index.ts new file mode 100644 index 000000000..7acee6870 --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/index.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Security Plugin — System Object Definitions (sys namespace) + * + * Canonical ObjectSchema definitions for security-related system objects. + */ + +export { SysRole } from './sys-role.object.js'; +export { SysPermissionSet } from './sys-permission-set.object.js'; diff --git a/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts new file mode 100644 index 000000000..43b50bfea --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_permission_set — System Permission Set Object + * + * Named groupings of fine-grained permissions. + * Permission sets can be assigned to roles or directly to users + * for granular access control. + * + * @namespace sys + */ +export const SysPermissionSet = ObjectSchema.create({ + namespace: 'sys', + name: 'permission_set', + label: 'Permission Set', + pluralLabel: 'Permission Sets', + icon: 'lock', + isSystem: true, + description: 'Named permission groupings for fine-grained access control', + titleFormat: '{name}', + compactLayout: ['name', 'label', 'active'], + + fields: { + id: Field.text({ + label: 'Permission Set ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + name: Field.text({ + label: 'API Name', + required: true, + searchable: true, + maxLength: 100, + description: 'Unique machine name for the permission set', + }), + + label: Field.text({ + label: 'Display Name', + required: true, + maxLength: 255, + }), + + description: Field.textarea({ + label: 'Description', + required: false, + }), + + object_permissions: Field.textarea({ + label: 'Object Permissions', + required: false, + description: 'JSON-serialized object-level CRUD permissions', + }), + + field_permissions: Field.textarea({ + label: 'Field Permissions', + required: false, + description: 'JSON-serialized field-level read/write permissions', + }), + + active: Field.boolean({ + label: 'Active', + defaultValue: true, + }), + }, + + indexes: [ + { fields: ['name'], unique: true }, + { fields: ['active'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: true, + mru: true, + }, +}); diff --git a/packages/plugins/plugin-security/src/objects/sys-role.object.ts b/packages/plugins/plugin-security/src/objects/sys-role.object.ts new file mode 100644 index 000000000..1057433e7 --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/sys-role.object.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_role — System Role Object + * + * RBAC role definition for the ObjectStack platform. + * Roles group permissions and are assigned to users or members. + * + * @namespace sys + */ +export const SysRole = ObjectSchema.create({ + namespace: 'sys', + name: 'role', + label: 'Role', + pluralLabel: 'Roles', + icon: 'shield', + isSystem: true, + description: 'Role definitions for RBAC access control', + titleFormat: '{name}', + compactLayout: ['name', 'label', 'active'], + + fields: { + id: Field.text({ + label: 'Role ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + name: Field.text({ + label: 'API Name', + required: true, + searchable: true, + maxLength: 100, + description: 'Unique machine name for the role (e.g. admin, editor, viewer)', + }), + + label: Field.text({ + label: 'Display Name', + required: true, + maxLength: 255, + }), + + description: Field.textarea({ + label: 'Description', + required: false, + }), + + permissions: Field.textarea({ + label: 'Permissions', + required: false, + description: 'JSON-serialized array of permission strings', + }), + + active: Field.boolean({ + label: 'Active', + defaultValue: true, + }), + + is_default: Field.boolean({ + label: 'Default Role', + defaultValue: false, + description: 'Automatically assigned to new users', + }), + }, + + indexes: [ + { fields: ['name'], unique: true }, + { fields: ['active'] }, + ], + + enable: { + trackHistory: true, + searchable: true, + apiEnabled: true, + apiMethods: ['get', 'list', 'create', 'update', 'delete'], + trash: true, + mru: true, + }, +}); diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 68ae9b88f..5db4bf954 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -744,3 +744,106 @@ describe('ObjectSchema.create()', () => { })).toThrow(); }); }); + +// ============================================================================ +// Namespace & Auto-Derivation +// ============================================================================ + +describe('ObjectSchema namespace', () => { + it('should accept namespace property', () => { + const result = ObjectSchema.safeParse({ + namespace: 'sys', + name: 'user', + fields: {}, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.namespace).toBe('sys'); + } + }); + + it('should accept object without namespace (optional)', () => { + const result = ObjectSchema.safeParse({ + name: 'account', + fields: {}, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.namespace).toBeUndefined(); + } + }); + + it('should reject invalid namespace format (must be lowercase alpha)', () => { + const result = ObjectSchema.safeParse({ + namespace: 'Sys', + name: 'user', + fields: {}, + }); + expect(result.success).toBe(false); + }); + + it('should reject namespace with underscores', () => { + const result = ObjectSchema.safeParse({ + namespace: 'my_ns', + name: 'user', + fields: {}, + }); + expect(result.success).toBe(false); + }); + + it('should reject namespace with hyphens', () => { + const result = ObjectSchema.safeParse({ + namespace: 'my-ns', + name: 'user', + fields: {}, + }); + expect(result.success).toBe(false); + }); +}); + +describe('ObjectSchema.create() namespace auto-derivation', () => { + it('should auto-derive tableName from namespace + name', () => { + const obj = ObjectSchema.create({ + namespace: 'sys', + name: 'user', + fields: {}, + }); + expect(obj.tableName).toBe('sys_user'); + }); + + it('should auto-derive tableName for multi-word name', () => { + const obj = ObjectSchema.create({ + namespace: 'sys', + name: 'audit_log', + fields: {}, + }); + expect(obj.tableName).toBe('sys_audit_log'); + }); + + it('should prefer explicit tableName over auto-derived', () => { + const obj = ObjectSchema.create({ + namespace: 'sys', + name: 'user', + tableName: 'custom_users', + fields: {}, + }); + expect(obj.tableName).toBe('custom_users'); + }); + + it('should not set tableName when namespace is absent', () => { + const obj = ObjectSchema.create({ + name: 'account', + fields: {}, + }); + expect(obj.tableName).toBeUndefined(); + }); + + it('should auto-derive tableName for business namespace', () => { + const obj = ObjectSchema.create({ + namespace: 'crm', + name: 'deal', + fields: {}, + }); + expect(obj.tableName).toBe('crm_deal'); + }); +}); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index b8c4d5d1e..8f444d881 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -244,6 +244,23 @@ const ObjectSchemaBase = z.object({ description: z.string().optional().describe('Developer documentation / description'), icon: z.string().optional().describe('Icon name (Lucide/Material) for UI representation'), + /** + * Namespace & Domain Classification + * + * Groups objects into logical domains for routing, permissions, and discovery. + * System objects use `'sys'`; business packages use their own namespace. + * + * When set, `tableName` is auto-derived as `{namespace}_{name}` by + * `ObjectSchema.create()` unless an explicit `tableName` is provided. + * + * Namespace must be a single lowercase word (no underscores or hyphens) + * to ensure clean auto-derivation of `{namespace}_{name}` table names. + * + * @example namespace: 'sys' → tableName defaults to 'sys_user' + * @example namespace: 'crm' → tableName defaults to 'crm_account' + */ + namespace: z.string().regex(/^[a-z][a-z0-9]*$/).optional().describe('Logical domain namespace — single lowercase word (e.g. "sys", "crm"). Used for routing, permissions, and auto-deriving tableName as {namespace}_{name}.'), + /** * Taxonomy & Organization */ @@ -256,7 +273,7 @@ const ObjectSchemaBase = z.object({ * Storage & Virtualization */ datasource: z.string().optional().default('default').describe('Target Datasource ID. "default" is the primary DB.'), - tableName: z.string().optional().describe('Physical table/collection name in the target datasource'), + tableName: z.string().optional().describe('Physical table/collection name in the target datasource. Auto-derived as {namespace}_{name} when namespace is set.'), /** * Data Model @@ -382,6 +399,8 @@ export const ObjectSchema = Object.assign(ObjectSchemaBase, { const withDefaults = { ...config, label: config.label ?? snakeCaseToLabel(config.name), + // Auto-derive tableName as {namespace}_{name} when namespace is set + tableName: config.tableName ?? (config.namespace ? `${config.namespace}_${config.name}` : undefined), }; return ObjectSchemaBase.parse(withDefaults) as Omit & Pick; }, diff --git a/packages/spec/src/system/constants/system-names.test.ts b/packages/spec/src/system/constants/system-names.test.ts index 798a648e0..29009f571 100644 --- a/packages/spec/src/system/constants/system-names.test.ts +++ b/packages/spec/src/system/constants/system-names.test.ts @@ -15,6 +15,16 @@ describe('SystemObjectName', () => { expect(SystemObjectName.SESSION).toBe('sys_session'); expect(SystemObjectName.ACCOUNT).toBe('sys_account'); expect(SystemObjectName.VERIFICATION).toBe('sys_verification'); + expect(SystemObjectName.ORGANIZATION).toBe('sys_organization'); + expect(SystemObjectName.MEMBER).toBe('sys_member'); + expect(SystemObjectName.INVITATION).toBe('sys_invitation'); + expect(SystemObjectName.TEAM).toBe('sys_team'); + expect(SystemObjectName.TEAM_MEMBER).toBe('sys_team_member'); + expect(SystemObjectName.API_KEY).toBe('sys_api_key'); + expect(SystemObjectName.TWO_FACTOR).toBe('sys_two_factor'); + expect(SystemObjectName.ROLE).toBe('sys_role'); + expect(SystemObjectName.PERMISSION_SET).toBe('sys_permission_set'); + expect(SystemObjectName.AUDIT_LOG).toBe('sys_audit_log'); expect(SystemObjectName.METADATA).toBe('sys_metadata'); }); @@ -22,6 +32,30 @@ describe('SystemObjectName', () => { const names: readonly string[] = Object.values(SystemObjectName); expect(names).toContain('sys_user'); expect(names).toContain('sys_session'); + expect(names).toContain('sys_organization'); + expect(names).toContain('sys_team'); + expect(names).toContain('sys_team_member'); + expect(names).toContain('sys_role'); + expect(names).toContain('sys_audit_log'); + }); + + it('should have all expected keys', () => { + const keys = Object.keys(SystemObjectName); + expect(keys).toContain('USER'); + expect(keys).toContain('SESSION'); + expect(keys).toContain('ACCOUNT'); + expect(keys).toContain('VERIFICATION'); + expect(keys).toContain('ORGANIZATION'); + expect(keys).toContain('MEMBER'); + expect(keys).toContain('INVITATION'); + expect(keys).toContain('TEAM'); + expect(keys).toContain('TEAM_MEMBER'); + expect(keys).toContain('API_KEY'); + expect(keys).toContain('TWO_FACTOR'); + expect(keys).toContain('ROLE'); + expect(keys).toContain('PERMISSION_SET'); + expect(keys).toContain('AUDIT_LOG'); + expect(keys).toContain('METADATA'); }); }); @@ -64,6 +98,22 @@ describe('StorageNameMapping', () => { it('should fall back to name when tableName is undefined', () => { expect(StorageNameMapping.resolveTableName({ name: 'session', tableName: undefined })).toBe('session'); }); + + it('should auto-derive table name from namespace + name', () => { + expect(StorageNameMapping.resolveTableName({ name: 'user', namespace: 'sys' })).toBe('sys_user'); + }); + + it('should prefer explicit tableName over namespace derivation', () => { + expect(StorageNameMapping.resolveTableName({ name: 'user', namespace: 'sys', tableName: 'custom_users' })).toBe('custom_users'); + }); + + it('should derive multi-word name with namespace', () => { + expect(StorageNameMapping.resolveTableName({ name: 'audit_log', namespace: 'sys' })).toBe('sys_audit_log'); + }); + + it('should fall back to name when namespace is undefined', () => { + expect(StorageNameMapping.resolveTableName({ name: 'account', namespace: undefined })).toBe('account'); + }); }); describe('resolveColumnName', () => { diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts index 4a3318efd..9b071c73b 100644 --- a/packages/spec/src/system/constants/system-names.ts +++ b/packages/spec/src/system/constants/system-names.ts @@ -28,6 +28,26 @@ export const SystemObjectName = { ACCOUNT: 'sys_account', /** Authentication: email / phone verification */ VERIFICATION: 'sys_verification', + /** Authentication: organization (multi-org support) */ + ORGANIZATION: 'sys_organization', + /** Authentication: organization member */ + MEMBER: 'sys_member', + /** Authentication: organization invitation */ + INVITATION: 'sys_invitation', + /** Authentication: team within an organization */ + TEAM: 'sys_team', + /** Authentication: team membership */ + TEAM_MEMBER: 'sys_team_member', + /** Authentication: API key for programmatic access */ + API_KEY: 'sys_api_key', + /** Authentication: two-factor authentication credentials */ + TWO_FACTOR: 'sys_two_factor', + /** Security: role definition for RBAC */ + ROLE: 'sys_role', + /** Security: permission set grouping */ + PERMISSION_SET: 'sys_permission_set', + /** Audit: system audit log */ + AUDIT_LOG: 'sys_audit_log', /** System metadata storage */ METADATA: 'sys_metadata', } as const; @@ -87,13 +107,13 @@ export type SystemFieldName = typeof SystemFieldName[keyof typeof SystemFieldNam export const StorageNameMapping = { /** * Resolve the physical table name for an object. - * Falls back to `object.name` when `tableName` is not set. + * Priority: explicit `tableName` → auto-derived `{namespace}_{name}` → `name`. * - * @param object - Object definition (at minimum `{ name: string; tableName?: string }`) + * @param object - Object definition (at minimum `{ name: string; namespace?: string; tableName?: string }`) * @returns The physical table / collection name to use in storage operations. */ - resolveTableName(object: { name: string; tableName?: string }): string { - return object.tableName ?? object.name; + resolveTableName(object: { name: string; namespace?: string; tableName?: string }): string { + return object.tableName ?? (object.namespace ? `${object.namespace}_${object.name}` : object.name); }, /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04c1a20f7..f25f19c81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -702,6 +702,22 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@25.3.5)(happy-dom@20.8.3)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@25.3.5)(typescript@5.9.3))(tsx@4.21.0) + packages/plugins/plugin-audit: + dependencies: + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@types/node': + specifier: ^25.3.5 + version: 25.3.5 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.3.5)(happy-dom@20.8.3)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.10(@types/node@25.3.5)(typescript@5.9.3))(tsx@4.21.0) + packages/plugins/plugin-auth: dependencies: '@objectstack/core':