Merge branch 'main' into fix/gro-485-oobe-staff-middleware
This commit is contained in:
@@ -31,16 +31,36 @@ jobs:
|
|||||||
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
||||||
sudo chmod +x /usr/local/bin/yq
|
sudo chmod +x /usr/local/bin/yq
|
||||||
|
|
||||||
- name: Update prod overlay image tags
|
- name: Update prod overlay image tags and base Job names
|
||||||
env:
|
env:
|
||||||
TAG: ${{ inputs.tag }}
|
TAG: ${{ inputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
cd /tmp/infra
|
cd /tmp/infra
|
||||||
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
|
PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
|
||||||
|
|
||||||
|
SHORT_SHA="${TAG##*-}"
|
||||||
|
export SHORT_SHA
|
||||||
|
export TAG
|
||||||
|
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$PROD_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$PROD_KUST"
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$PROD_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$PROD_KUST"
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST"
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
|
||||||
|
|
||||||
|
# Update migrate Job name to include short SHA (immutable template fix)
|
||||||
|
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||||
|
if [ -f "$MIGRATE_JOB" ]; then
|
||||||
|
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||||
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update seed Job name to include short SHA (immutable template fix)
|
||||||
|
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||||
|
if [ -f "$SEED_JOB" ]; then
|
||||||
|
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||||
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||||
|
fi
|
||||||
|
|
||||||
git -C /tmp/infra diff --stat
|
git -C /tmp/infra diff --stat
|
||||||
|
|
||||||
- name: Create PR on groombook/infra
|
- name: Create PR on groombook/infra
|
||||||
@@ -52,7 +72,7 @@ jobs:
|
|||||||
git config user.name "groombook-engineer[bot]"
|
git config user.name "groombook-engineer[bot]"
|
||||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||||
git checkout -b "release/promote-prod-${TAG}"
|
git checkout -b "release/promote-prod-${TAG}"
|
||||||
git add apps/groombook/overlays/prod/
|
git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
||||||
git commit -m "release: promote ${TAG} to production"
|
git commit -m "release: promote ${TAG} to production"
|
||||||
git push -u origin "release/promote-prod-${TAG}"
|
git push -u origin "release/promote-prod-${TAG}"
|
||||||
gh pr create \
|
gh pr create \
|
||||||
@@ -61,3 +81,15 @@ jobs:
|
|||||||
--head "release/promote-prod-${TAG}" \
|
--head "release/promote-prod-${TAG}" \
|
||||||
--title "release: promote ${TAG} to production" \
|
--title "release: promote ${TAG} to production" \
|
||||||
--body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood"
|
--body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood"
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: '## Production Promotion Failed\n\nThe `promote-prod` workflow failed. Check the workflow run logs for details.'
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
|
||||||
sudo chmod +x /usr/local/bin/yq
|
sudo chmod +x /usr/local/bin/yq
|
||||||
|
|
||||||
- name: Update UAT overlay image tags
|
- name: Update UAT overlay image tags and base Job names
|
||||||
env:
|
env:
|
||||||
TAG: ${{ inputs.image_tag }}
|
TAG: ${{ inputs.image_tag }}
|
||||||
run: |
|
run: |
|
||||||
@@ -45,11 +45,29 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
SHORT_SHA="${TAG##*-}"
|
||||||
|
export SHORT_SHA
|
||||||
|
export TAG
|
||||||
|
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$UAT_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$UAT_KUST"
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$UAT_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$UAT_KUST"
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST"
|
||||||
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
|
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
|
||||||
|
|
||||||
|
# Update migrate Job name to include short SHA (immutable template fix)
|
||||||
|
MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
|
||||||
|
if [ -f "$MIGRATE_JOB" ]; then
|
||||||
|
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
|
||||||
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update seed Job name to include short SHA (immutable template fix)
|
||||||
|
SEED_JOB="apps/groombook/base/seed-job.yaml"
|
||||||
|
if [ -f "$SEED_JOB" ]; then
|
||||||
|
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
|
||||||
|
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
|
||||||
|
fi
|
||||||
|
|
||||||
git -C /tmp/infra diff --stat
|
git -C /tmp/infra diff --stat
|
||||||
|
|
||||||
- name: Create PR on groombook/infra
|
- name: Create PR on groombook/infra
|
||||||
@@ -61,7 +79,7 @@ jobs:
|
|||||||
git config user.name "groombook-engineer[bot]"
|
git config user.name "groombook-engineer[bot]"
|
||||||
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
|
||||||
git checkout -b "chore/update-uat-image-tags-${TAG}"
|
git checkout -b "chore/update-uat-image-tags-${TAG}"
|
||||||
git add apps/groombook/overlays/uat/
|
git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
|
||||||
git commit -m "chore: promote ${TAG} to UAT"
|
git commit -m "chore: promote ${TAG} to UAT"
|
||||||
|
|
||||||
git push -u origin "chore/update-uat-image-tags-${TAG}"
|
git push -u origin "chore/update-uat-image-tags-${TAG}"
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ describe("encryptSecret / decryptSecret", () => {
|
|||||||
expect(decrypted).toBe(plaintext);
|
expect(decrypted).toBe(plaintext);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("produces output in iv:ciphertext:authTag format", () => {
|
it("produces output in salt:iv:ciphertext:authTag format", () => {
|
||||||
const encrypted = encryptSecret("test");
|
const encrypted = encryptSecret("test");
|
||||||
const parts = encrypted.split(":");
|
const parts = encrypted.split(":");
|
||||||
|
|
||||||
expect(parts).toHaveLength(3);
|
expect(parts).toHaveLength(4);
|
||||||
// Each part should be valid base64
|
// Each part should be valid base64
|
||||||
parts.forEach((part) => {
|
parts.forEach((part) => {
|
||||||
expect(() => Buffer.from(part, "base64")).not.toThrow();
|
expect(() => Buffer.from(part, "base64")).not.toThrow();
|
||||||
@@ -62,11 +62,12 @@ describe("encryptSecret / decryptSecret", () => {
|
|||||||
|
|
||||||
it("throws when decrypting invalid format (wrong number of parts)", () => {
|
it("throws when decrypting invalid format (wrong number of parts)", () => {
|
||||||
const encrypted = encryptSecret("test");
|
const encrypted = encryptSecret("test");
|
||||||
// Replace the last ":authTag" part by matching colon + non-colon chars at the end
|
// Replace the last two parts with a single part to create a 2-part string
|
||||||
const invalid = encrypted.replace(/:[^:]+$/, "");
|
// This can't be parsed as either legacy (3 parts) or new (4 parts) format
|
||||||
|
const invalid = encrypted.replace(/:[^:]+$/, "").replace(/:[^:]+$/, "");
|
||||||
|
|
||||||
expect(() => decryptSecret(invalid)).toThrow(
|
expect(() => decryptSecret(invalid)).toThrow(
|
||||||
"Invalid encrypted value format: expected iv:ciphertext:authTag"
|
"Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -167,7 +167,6 @@ api.route("/impersonation", impersonationRouter);
|
|||||||
api.route("/admin/settings", settingsRouter);
|
api.route("/admin/settings", settingsRouter);
|
||||||
api.route("/admin/auth-provider", authProviderRouter);
|
api.route("/admin/auth-provider", authProviderRouter);
|
||||||
api.route("/admin/seed", adminSeedRouter);
|
api.route("/admin/seed", adminSeedRouter);
|
||||||
api.route("/admin/auth-provider", authProviderRouter);
|
|
||||||
api.route("/search", searchRouter);
|
api.route("/search", searchRouter);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { eq, getDb, staff } from "@groombook/db";
|
import { and, eq, getDb, isNull, staff } from "@groombook/db";
|
||||||
|
|
||||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
@@ -90,6 +90,25 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
.from(staff)
|
.from(staff)
|
||||||
.where(eq(staff.oidcSub, jwt.sub));
|
.where(eq(staff.oidcSub, jwt.sub));
|
||||||
if (!fallbackRow) {
|
if (!fallbackRow) {
|
||||||
|
// Auto-link: staff record exists with matching email but no userId — link it now
|
||||||
|
if (jwt.email) {
|
||||||
|
const [linkedStaff] = await db
|
||||||
|
.select()
|
||||||
|
.from(staff)
|
||||||
|
.where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
|
||||||
|
if (linkedStaff) {
|
||||||
|
await db
|
||||||
|
.update(staff)
|
||||||
|
.set({ userId: jwt.sub })
|
||||||
|
.where(eq(staff.id, linkedStaff.id));
|
||||||
|
console.log(
|
||||||
|
`[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}`
|
||||||
|
);
|
||||||
|
c.set("staff", linkedStaff);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||||
403
|
403
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||||
|
navigateFallbackDenylist: [
|
||||||
|
/^\/api\/auth\/oauth2\/callback\//,
|
||||||
|
],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: /^http.*\/api\/.*/i,
|
urlPattern: /^http.*\/api\/.*/i,
|
||||||
|
|||||||
+32
-20
@@ -7,18 +7,15 @@ const SALT_LENGTH = 16;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt.
|
* Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt.
|
||||||
* BETTER_AUTH_SECRET is used as the password, with a fixed salt derived from the package name.
|
* A unique random salt is generated per encryptSecret() call and prepended to the output.
|
||||||
*/
|
*/
|
||||||
function deriveKey(secret: string): Buffer {
|
function deriveKey(secret: string, salt: Buffer): Buffer {
|
||||||
// Use a fixed salt derived from the package name for key derivation
|
return scryptSync(secret, salt, 32);
|
||||||
// This gives us stable key derivation without storing an extra salt
|
|
||||||
const packageSalt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
|
|
||||||
return scryptSync(secret, packageSalt, 32);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypts a plaintext string using AES-256-GCM.
|
* Encrypts a plaintext string using AES-256-GCM.
|
||||||
* Returns a base64-encoded string in the format: iv:ciphertext:authTag
|
* Returns a base64-encoded string in the format: salt:iv:ciphertext:authTag
|
||||||
*/
|
*/
|
||||||
export function encryptSecret(plaintext: string): string {
|
export function encryptSecret(plaintext: string): string {
|
||||||
const secret = process.env.BETTER_AUTH_SECRET;
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
@@ -26,7 +23,8 @@ export function encryptSecret(plaintext: string): string {
|
|||||||
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = deriveKey(secret);
|
const salt = randomBytes(SALT_LENGTH);
|
||||||
|
const key = deriveKey(secret, salt);
|
||||||
const iv = randomBytes(IV_LENGTH);
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
const cipher = createCipheriv(ALGORITHM, key, iv, {
|
const cipher = createCipheriv(ALGORITHM, key, iv, {
|
||||||
@@ -38,8 +36,9 @@ export function encryptSecret(plaintext: string): string {
|
|||||||
|
|
||||||
const authTag = cipher.getAuthTag();
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
// Format: base64(iv):base64(ciphertext):base64(authTag)
|
// Format: base64(salt):base64(iv):base64(ciphertext):base64(authTag)
|
||||||
return [
|
return [
|
||||||
|
salt.toString("base64"),
|
||||||
iv.toString("base64"),
|
iv.toString("base64"),
|
||||||
ciphertext.toString("base64"),
|
ciphertext.toString("base64"),
|
||||||
authTag.toString("base64"),
|
authTag.toString("base64"),
|
||||||
@@ -48,7 +47,7 @@ export function encryptSecret(plaintext: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypts a ciphertext string produced by encryptSecret.
|
* Decrypts a ciphertext string produced by encryptSecret.
|
||||||
* Expects the format: iv:ciphertext:authTag (all base64-encoded)
|
* Supports both new format (salt:iv:ciphertext:authTag) and legacy format (iv:ciphertext:authTag).
|
||||||
*/
|
*/
|
||||||
export function decryptSecret(encrypted: string): string {
|
export function decryptSecret(encrypted: string): string {
|
||||||
const secret = process.env.BETTER_AUTH_SECRET;
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
@@ -57,18 +56,31 @@ export function decryptSecret(encrypted: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parts = encrypted.split(":");
|
const parts = encrypted.split(":");
|
||||||
if (parts.length !== 3) {
|
|
||||||
throw new Error("Invalid encrypted value format: expected iv:ciphertext:authTag");
|
let salt: Buffer;
|
||||||
|
let iv: Buffer;
|
||||||
|
let ciphertext: Buffer;
|
||||||
|
let authTag: Buffer;
|
||||||
|
|
||||||
|
if (parts.length === 4) {
|
||||||
|
// New format: salt:iv:ciphertext:authTag
|
||||||
|
salt = Buffer.from(parts[0]!, "base64");
|
||||||
|
iv = Buffer.from(parts[1]!, "base64");
|
||||||
|
ciphertext = Buffer.from(parts[2]!, "base64");
|
||||||
|
authTag = Buffer.from(parts[3]!, "base64");
|
||||||
|
} else if (parts.length === 3) {
|
||||||
|
// Legacy format: iv:ciphertext:authTag — use fixed package salt
|
||||||
|
salt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
|
||||||
|
iv = Buffer.from(parts[0]!, "base64");
|
||||||
|
ciphertext = Buffer.from(parts[1]!, "base64");
|
||||||
|
authTag = Buffer.from(parts[2]!, "base64");
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ivBase64 = parts[0]!;
|
const key = deriveKey(secret, salt);
|
||||||
const ciphertextBase64 = parts[1]!;
|
|
||||||
const authTagBase64 = parts[2]!;
|
|
||||||
const iv = Buffer.from(ivBase64, "base64");
|
|
||||||
const ciphertext = Buffer.from(ciphertextBase64, "base64");
|
|
||||||
const authTag = Buffer.from(authTagBase64, "base64");
|
|
||||||
|
|
||||||
const key = deriveKey(secret);
|
|
||||||
|
|
||||||
const decipher = createDecipheriv(ALGORITHM, key, iv, {
|
const decipher = createDecipheriv(ALGORITHM, key, iv, {
|
||||||
authTagLength: AUTH_TAG_LENGTH,
|
authTagLength: AUTH_TAG_LENGTH,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as schema from "./schema.js";
|
|||||||
|
|
||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
export { encryptSecret, decryptSecret } from "./crypto.js";
|
export { encryptSecret, decryptSecret } from "./crypto.js";
|
||||||
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm";
|
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
|
||||||
|
|
||||||
let _db: ReturnType<typeof drizzle> | null = null;
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user