1313 * plane: each project kernel only knows about its own data driver.
1414 *
1515 * The kernel is bootstrapped with:
16- * • DriverPlugin(driver) — project-scoped data driver
16+ * • DriverPlugin(driver) — project-scoped data driver, also aliased
17+ * as the `'cloud'` datasource so AuthPlugin's
18+ * identity manifest resolves locally.
1719 * • ObjectQLPlugin
1820 * • MetadataPlugin (no system-object registration)
21+ * • AuthPlugin — per-project, derives an HKDF secret from
22+ * `OS_AUTH_SECRET` + projectId. Each project owns its
23+ * own `sys_user/sys_session/...` tables in its own
24+ * Turso DB. Cookies are scoped to the project's
25+ * hostname (no `.<root>`-wide cross-project leak).
1926 * • AppPlugin(artifact.metadata) — compiled developer code
2027 */
2128
29+ import { createHmac } from 'node:crypto' ;
2230import { ObjectKernel } from '@objectstack/core' ;
2331import type * as Contracts from '@objectstack/spec/contracts' ;
2432import { DriverPlugin , AppPlugin } from '@objectstack/runtime' ;
@@ -35,19 +43,44 @@ export interface ArtifactKernelFactoryConfig {
3543 logger ?: { info ?: ( ...a : any [ ] ) => void ; warn ?: ( ...a : any [ ] ) => void ; error ?: ( ...a : any [ ] ) => void } ;
3644 /** Optional kernel constructor config. */
3745 kernelConfig ?: ConstructorParameters < typeof ObjectKernel > [ 0 ] ;
46+ /**
47+ * Base secret used to derive per-project AuthPlugin secrets via
48+ * HKDF-style HMAC-SHA256(baseSecret, projectId). Falls back to
49+ * `process.env.OS_AUTH_SECRET` / `AUTH_SECRET` at construction time.
50+ */
51+ authBaseSecret ?: string ;
52+ }
53+
54+ /**
55+ * Derive a deterministic per-project auth secret. HMAC-SHA256 of the
56+ * projectId keyed by the base secret yields a 64-char hex string that is:
57+ * - stable across container cold-starts (no DB lookup needed)
58+ * - independent per project (forging a token on project A does not
59+ * compromise project B)
60+ * - rotatable by changing the base secret (will invalidate all sessions)
61+ */
62+ function deriveProjectAuthSecret ( baseSecret : string , projectId : string ) : string {
63+ return createHmac ( 'sha256' , baseSecret ) . update ( `project:${ projectId } ` ) . digest ( 'hex' ) ;
3864}
3965
4066export class ArtifactKernelFactory implements ProjectKernelFactory {
4167 private readonly client : ArtifactApiClient ;
4268 private readonly envRegistry : EnvironmentDriverRegistry ;
4369 private readonly logger : NonNullable < ArtifactKernelFactoryConfig [ 'logger' ] > ;
4470 private readonly kernelConfig ?: ArtifactKernelFactoryConfig [ 'kernelConfig' ] ;
71+ private readonly authBaseSecret : string ;
4572
4673 constructor ( config : ArtifactKernelFactoryConfig ) {
4774 this . client = config . client ;
4875 this . envRegistry = config . envRegistry ;
4976 this . logger = config . logger ?? console ;
5077 this . kernelConfig = config . kernelConfig ;
78+ this . authBaseSecret = (
79+ config . authBaseSecret
80+ ?? process . env . OS_AUTH_SECRET
81+ ?? process . env . AUTH_SECRET
82+ ?? ''
83+ ) . trim ( ) ;
5184 }
5285
5386 async create ( projectId : string ) : Promise < ObjectKernel > {
@@ -76,15 +109,62 @@ export class ArtifactKernelFactory implements ProjectKernelFactory {
76109
77110 const kernel = new ObjectKernel ( this . kernelConfig ) ;
78111
79- await kernel . use ( new DriverPlugin ( driver ) ) ;
80- await kernel . use ( new ObjectQLPlugin ( { projectId : projectId } ) ) ;
112+ // Register the project driver as both the unnamed default AND under
113+ // the `'cloud'` alias. AuthPlugin's manifest header historically
114+ // declares `defaultDatasource: 'cloud'`; aliasing here keeps that
115+ // path working without forcing every project's identity table
116+ // through a control-plane proxy.
117+ await kernel . use ( new DriverPlugin ( driver , { datasourceName : 'cloud' } as any ) ) ;
118+ // Enable schema sync per-project so sys_user / sys_session / etc.
119+ // tables get created on the project's own DB. The host worker sets
120+ // `OS_SKIP_SCHEMA_SYNC=1` for the control-plane DB; that env var
121+ // must NOT bleed into project kernels because their auth tables
122+ // need provisioning. KernelManager caches kernels so this runs
123+ // at most once per cold-start per project.
124+ await kernel . use ( new ObjectQLPlugin ( { projectId : projectId , skipSchemaSync : false } ) ) ;
81125 await kernel . use ( new MetadataPlugin ( {
82126 watch : false ,
83127 projectId : projectId ,
84128 organizationId : project . organization_id ,
85129 registerSystemObjects : false ,
86130 } ) ) ;
87131
132+ // Per-project AuthPlugin — only when an OS_AUTH_SECRET base is
133+ // configured. Without it we cannot derive a secret deterministically
134+ // and refuse to start auth (better silent-fail than insecure default).
135+ if ( this . authBaseSecret ) {
136+ try {
137+ const { AuthPlugin } = await import ( '@objectstack/plugin-auth' ) ;
138+ const projectSecret = deriveProjectAuthSecret ( this . authBaseSecret , projectId ) ;
139+ const baseUrl = project . hostname
140+ ? ( project . hostname . startsWith ( 'http' ) ? project . hostname : `https://${ project . hostname } ` )
141+ : undefined ;
142+ await kernel . use ( new AuthPlugin ( {
143+ secret : projectSecret ,
144+ baseUrl,
145+ // Project kernel has no http-server (host owns it). The
146+ // dispatcher's handleAuth path resolves `auth` via
147+ // getService and invokes the handler directly — route
148+ // registration is unnecessary and would warn.
149+ registerRoutes : false ,
150+ // Identity tables live in the project's own DB — keep
151+ // sys_user/sys_session local to this kernel.
152+ manifestDatasource : 'default' ,
153+ // Cookie scope: default to the project's own host. We
154+ // intentionally do NOT pass crossSubDomainCookies here
155+ // so cookies stay isolated per project subdomain.
156+ trustedOrigins : baseUrl ? [ baseUrl ] : undefined ,
157+ } as any ) ) ;
158+ } catch ( err : any ) {
159+ this . logger . warn ?.( '[ArtifactKernelFactory] AuthPlugin not registered' , {
160+ projectId,
161+ error : err ?. message ,
162+ } ) ;
163+ }
164+ } else {
165+ this . logger . warn ?.( '[ArtifactKernelFactory] OS_AUTH_SECRET not set — per-project AuthPlugin skipped (auth endpoints will return 404)' , { projectId } ) ;
166+ }
167+
88168 const projectName = project . hostname ?? projectId ;
89169 const bundle = artifact . metadata as any ;
90170 const sys = bundle ?. manifest ?? bundle ;
@@ -104,6 +184,7 @@ export class ArtifactKernelFactory implements ProjectKernelFactory {
104184 projectId,
105185 commitId : artifact . commitId ,
106186 checksum : artifact . checksum ,
187+ authEnabled : Boolean ( this . authBaseSecret ) ,
107188 } ) ;
108189
109190 return kernel ;
0 commit comments