diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 290359d..5cdf35a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -139,6 +139,8 @@ jobs: - name: Install Dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' || steps.validate-cache.outputs.valid == 'false' run: npm ci + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Type check run: npm run typecheck @@ -190,6 +192,8 @@ jobs: - name: Install Dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' || steps.validate-cache.outputs.valid == 'false' run: npm ci + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Run affected unit tests run: | @@ -302,6 +306,7 @@ jobs: load: true push: false tags: wxyc_auth_service:ci + build-args: NPM_TOKEN=${{ secrets.NPM_TOKEN }} cache-from: type=gha,scope=auth cache-to: type=gha,mode=max,scope=auth @@ -334,6 +339,7 @@ jobs: load: true push: false tags: wxyc_backend_service:ci + build-args: NPM_TOKEN=${{ secrets.NPM_TOKEN }} cache-from: type=gha,scope=backend cache-to: type=gha,mode=max,scope=backend @@ -366,6 +372,8 @@ jobs: - name: Install Dependencies if: env.RUN_TESTS == 'true' && (steps.cache-node-modules.outputs.cache-hit != 'true' || steps.validate-cache.outputs.valid == 'false') run: npm ci + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Lint .env file if: env.RUN_TESTS == 'true' @@ -373,6 +381,8 @@ jobs: - name: Set Up Test Environment if: env.RUN_TESTS == 'true' + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | touch .env npm run ci:env diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..46c8aba --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +@wxyc:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${NPM_TOKEN} diff --git a/Dockerfile.auth b/Dockerfile.auth index 1095cb2..2b24674 100644 --- a/Dockerfile.auth +++ b/Dockerfile.auth @@ -1,8 +1,10 @@ #Build stage FROM node:22-alpine AS builder +ARG NPM_TOKEN WORKDIR /auth-builder +COPY ./.npmrc ./ COPY ./package.json ./ COPY ./tsconfig.base.json ./ COPY ./shared ./shared @@ -13,14 +15,16 @@ RUN npm install && npm run build --workspace=@wxyc/database --workspace=shared/* #Production stage FROM node:22-alpine AS prod +ARG NPM_TOKEN WORKDIR /auth-service +COPY ./.npmrc ./ COPY ./package* ./ COPY ./apps/auth/package* ./apps/auth/ COPY ./shared/database/package* ./shared/database/ COPY ./shared/authentication/package* ./shared/authentication/ -RUN npm install --omit=dev +RUN npm install --omit=dev && rm -f .npmrc COPY --from=builder ./auth-builder/apps/auth/dist ./apps/auth/dist COPY --from=builder ./auth-builder/shared/database/dist ./shared/database/dist diff --git a/Dockerfile.backend b/Dockerfile.backend index a31baec..2966e84 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -1,8 +1,10 @@ #Build stage FROM node:22-alpine AS builder +ARG NPM_TOKEN WORKDIR /builder +COPY ./.npmrc ./ COPY ./package.json ./ COPY ./tsconfig.base.json ./ COPY ./shared ./shared @@ -13,14 +15,16 @@ RUN npm install && npm run build --workspace=@wxyc/database --workspace=shared/* #Production stage FROM node:22-alpine AS prod +ARG NPM_TOKEN WORKDIR /application +COPY ./.npmrc ./ COPY ./package* ./ COPY ./apps/backend/package* ./apps/backend/ COPY ./shared/database/package* ./shared/database/ COPY ./shared/authentication/package* ./shared/authentication/ -RUN npm install --omit=dev +RUN npm install --omit=dev && rm -f .npmrc COPY --from=builder ./builder/apps/backend/dist ./apps/backend/dist COPY --from=builder ./builder/shared/database/dist ./shared/database/dist diff --git a/apps/auth/package.json b/apps/auth/package.json index 4841dd7..ed9eefb 100644 --- a/apps/auth/package.json +++ b/apps/auth/package.json @@ -9,7 +9,7 @@ "start": "node dist/app.js", "build": "tsup --minify", "clean": "rm -rf dist", - "docker:build": "docker build -t wxyc_auth_service:ci -f ../../Dockerfile.auth ../../", + "docker:build": "docker build --build-arg NPM_TOKEN=$NPM_TOKEN -t wxyc_auth_service:ci -f ../../Dockerfile.auth ../../", "dev": "tsup --watch", "typecheck": "tsc --noEmit" }, diff --git a/apps/backend/package.json b/apps/backend/package.json index 67e2d14..13cd0c3 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -8,7 +8,7 @@ "start": "node dist/app.js", "build": "tsup --minify", "clean": "rm -rf dist", - "docker:build": "docker build -t wxyc_backend_service:ci -f ../../Dockerfile.backend ../../", + "docker:build": "docker build --build-arg NPM_TOKEN=$NPM_TOKEN -t wxyc_backend_service:ci -f ../../Dockerfile.backend ../../", "dev": "tsup --watch", "typecheck": "tsc --noEmit" }, diff --git a/jest.unit.config.ts b/jest.unit.config.ts index 5cd7809..4fad4bb 100644 --- a/jest.unit.config.ts +++ b/jest.unit.config.ts @@ -6,14 +6,14 @@ const config: Config = { testMatch: ['/tests/unit/**/*.test.ts'], setupFilesAfterEnv: ['/tests/setup/unit.setup.ts'], transform: { - '^.+\\.tsx?$': [ + '^.+\\.[jt]sx?$': [ 'ts-jest', { tsconfig: '/tests/tsconfig.json', }, ], }, - transformIgnorePatterns: ['node_modules/(?!(jose|drizzle-orm)/)'], + transformIgnorePatterns: ['node_modules/(?!(jose|drizzle-orm|@wxyc/shared)/)'], moduleNameMapper: { // Mock workspace database package '^@wxyc/database$': '/tests/mocks/database.mock.ts', diff --git a/package-lock.json b/package-lock.json index 9ef6de8..afde772 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5076,6 +5076,19 @@ "resolved": "shared/database", "link": true }, + "node_modules/@wxyc/shared": { + "version": "0.2.0", + "resolved": "https://npm.pkg.github.com/download/@wxyc/shared/0.2.0/ce6b71bf6b1b2cec5f559eeee8f917de3130cbcc", + "integrity": "sha512-vsST4GiDUBllhmonaExQf9bPjGGIXpkcPElc4EMOklRaqMhqevXKf0migb7ADb9iKtMDYfn4DcG9llwrDAAaAA==", + "license": "MIT", + "dependencies": { + "better-auth": "^1.4.9", + "date-fns": "^4.1.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -6264,6 +6277,16 @@ "node": ">= 12" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -12555,7 +12578,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -13120,6 +13142,7 @@ "dependencies": { "@aws-sdk/client-ses": "^3.971.0", "@wxyc/database": "^1.0.0", + "@wxyc/shared": "^0.2.0", "better-auth": "^1.3.23", "drizzle-orm": "^0.41.0", "postgres": "^3.4.4" diff --git a/shared/authentication/package.json b/shared/authentication/package.json index 55caad5..8e956f4 100644 --- a/shared/authentication/package.json +++ b/shared/authentication/package.json @@ -21,6 +21,7 @@ "dependencies": { "@aws-sdk/client-ses": "^3.971.0", "@wxyc/database": "^1.0.0", + "@wxyc/shared": "^0.2.0", "better-auth": "^1.3.23", "drizzle-orm": "^0.41.0", "postgres": "^3.4.4" diff --git a/shared/authentication/src/auth.roles.ts b/shared/authentication/src/auth.roles.ts index 8bbffb5..caaae1d 100644 --- a/shared/authentication/src/auth.roles.ts +++ b/shared/authentication/src/auth.roles.ts @@ -44,16 +44,29 @@ export const WXYCRoles = { stationManager, }; -export type WXYCRole = keyof typeof WXYCRoles; +import type { WXYCRole } from '@wxyc/shared/auth-client/auth'; +export type { WXYCRole } from '@wxyc/shared/auth-client/auth'; +export { roleToAuthorization, Authorization } from '@wxyc/shared/auth-client/auth'; + +// Compile-time assertion: every role in WXYCRoles is a valid shared WXYCRole. +// The reverse is intentionally not asserted -- shared includes "admin", which +// Backend-Service maps to "stationManager" via normalizeRole() rather than +// defining as a separate better-auth role. +type _AssertLocalRolesAreShared = [keyof typeof WXYCRoles] extends [WXYCRole] ? true : never; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _localRolesValid: _AssertLocalRolesAreShared = true; + +/** The set of roles that have a better-auth access control implementation. */ +export type ImplementedRole = keyof typeof WXYCRoles; /** Maps better-auth system roles to their WXYC equivalent. */ -const systemRoleMap: Record = { +const systemRoleMap: Record = { admin: 'stationManager', owner: 'stationManager', }; -/** Normalizes a role string to a WXYCRole, mapping better-auth system roles. */ -export function normalizeRole(role: string): WXYCRole | undefined { - if (role in WXYCRoles) return role as WXYCRole; +/** Normalizes a role string to an implemented role, mapping better-auth system roles. */ +export function normalizeRole(role: string): ImplementedRole | undefined { + if (role in WXYCRoles) return role as ImplementedRole; return systemRoleMap[role]; } diff --git a/shared/authentication/tsup.config.ts b/shared/authentication/tsup.config.ts index d7f55f3..96eca7c 100644 --- a/shared/authentication/tsup.config.ts +++ b/shared/authentication/tsup.config.ts @@ -8,5 +8,5 @@ export default defineConfig({ outDir: 'dist', clean: true, sourcemap: true, - external: ['drizzle-orm', 'postgres', 'better-auth'], + external: ['drizzle-orm', 'postgres', 'better-auth', '@wxyc/shared'], }); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 2e805ed..4f519ff 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "Node", "esModuleInterop": true, "strict": false, + "allowJs": true, "skipLibCheck": true, "declaration": false, "noEmit": true, diff --git a/tests/unit/authentication/shared-type-compatibility.test.ts b/tests/unit/authentication/shared-type-compatibility.test.ts new file mode 100644 index 0000000..ce5c2c8 --- /dev/null +++ b/tests/unit/authentication/shared-type-compatibility.test.ts @@ -0,0 +1,47 @@ +import { WXYCRoles, normalizeRole, type WXYCRole } from '../../../shared/authentication/src/auth.roles'; +import { Authorization, roleToAuthorization, type WXYCRole as SharedWXYCRole } from '@wxyc/shared/auth-client/auth'; + +describe('shared type compatibility', () => { + describe('WXYCRoles alignment', () => { + it.each(Object.keys(WXYCRoles) as WXYCRole[])('"%s" is a valid SharedWXYCRole', (role) => { + // Every role in Backend-Service's WXYCRoles must be a valid shared WXYCRole. + // This is also enforced at compile time by the type assertion in auth.roles.ts. + const sharedRole: SharedWXYCRole = role; + expect(sharedRole).toBe(role); + }); + }); + + describe('Authorization enum', () => { + it('has expected values', () => { + expect(Authorization.NO).toBe(0); + expect(Authorization.DJ).toBe(1); + expect(Authorization.MD).toBe(2); + expect(Authorization.SM).toBe(3); + expect(Authorization.ADMIN).toBe(4); + }); + }); + + describe('normalizeRole consistency with roleToAuthorization', () => { + it('admin normalizes to stationManager, consistent with shared ADMIN >= SM', () => { + expect(normalizeRole('admin')).toBe('stationManager'); + // Shared maps "admin" to ADMIN (4), which is >= SM (3). + // Both grant full access; the normalization is a backend-specific concern. + expect(roleToAuthorization('admin')).toBe(Authorization.ADMIN); + }); + + it.each(['member', 'dj', 'musicDirector', 'stationManager'] as const)( + '"%s" maps to the same Authorization via both paths', + (role) => { + // Direct shared mapping + const sharedAuth = roleToAuthorization(role); + // Backend path: normalizeRole returns the role as-is, then shared maps it + const normalized = normalizeRole(role); + expect(normalized).toBe(role); + expect(normalized).toBeDefined(); + if (normalized) { + expect(roleToAuthorization(normalized)).toBe(sharedAuth); + } + } + ); + }); +});