Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/identity-service/migrations/0001_initial_identity.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
CREATE SCHEMA IF NOT EXISTS identity;

CREATE TABLE identity.users (
id uuid PRIMARY KEY,
display_name text NOT NULL CHECK (length(btrim(display_name)) > 0),
created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE identity.external_identities (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
provider text NOT NULL CHECK (provider IN ('google', 'github')),
provider_subject text NOT NULL CHECK (length(btrim(provider_subject)) > 0),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT external_identity_provider_subject_unique UNIQUE (provider, provider_subject)
);

CREATE TABLE identity.workspaces (
id uuid PRIMARY KEY,
owner_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
name text NOT NULL CHECK (length(btrim(name)) > 0),
kind text NOT NULL DEFAULT 'personal' CHECK (kind IN ('personal')),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT one_personal_workspace_per_owner UNIQUE (owner_user_id, kind)
);

CREATE INDEX external_identities_user_idx
ON identity.external_identities (user_id);

CREATE INDEX workspaces_owner_idx
ON identity.workspaces (owner_user_id);
59 changes: 59 additions & 0 deletions apps/identity-service/src/identity-domain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { IdentityService, InMemoryIdentityRepository } from './identity-domain';

const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

describe('IdentityService', () => {
it('provisions one user and one personal workspace for a new external identity', () => {
const service = new IdentityService(new InMemoryIdentityRepository());

const account = service.signInWithExternalIdentity({
provider: 'github',
providerSubject: '8172694',
displayName: 'Example User',
});

expect(account.user.id).toMatch(UUID_V4_PATTERN);
expect(account.workspace.id).toMatch(UUID_V4_PATTERN);
expect(account.user.id).not.toBe(account.workspace.id);
expect(account.workspace.ownerUserId).toBe(account.user.id);
});

it('reuses the same internal account for repeated sign-in', () => {
const service = new IdentityService(new InMemoryIdentityRepository());
const input = {
provider: 'google' as const,
providerSubject: 'external-subject-123',
displayName: 'Example User',
};

const first = service.signInWithExternalIdentity(input);
const second = service.signInWithExternalIdentity(input);

expect(second).toEqual(first);
});

it('keeps provider subjects separate from internal identifiers', () => {
const service = new IdentityService(new InMemoryIdentityRepository());
const account = service.signInWithExternalIdentity({
provider: 'github',
providerSubject: '123456',
displayName: 'Example User',
});

expect(account.user.id).not.toBe('123456');
expect(account.externalIdentity.providerSubject).toBe('123456');
});

it('rejects an empty provider subject', () => {
const service = new IdentityService(new InMemoryIdentityRepository());

expect(() =>
service.signInWithExternalIdentity({
provider: 'google',
providerSubject: ' ',
displayName: 'Example User',
}),
).toThrowError('Provider subject is required');
});
});
110 changes: 110 additions & 0 deletions apps/identity-service/src/identity-domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { randomUUID } from 'node:crypto';

export type IdentityProvider = 'google' | 'github';

export interface User {
id: string;
displayName: string;
createdAt: string;
}

export interface ExternalIdentity {
id: string;
userId: string;
provider: IdentityProvider;
providerSubject: string;
createdAt: string;
}

export interface Workspace {
id: string;
ownerUserId: string;
name: string;
kind: 'personal';
createdAt: string;
}

export interface ProvisionedAccount {
user: User;
externalIdentity: ExternalIdentity;
workspace: Workspace;
}

export interface IdentityRepository {
findByExternalIdentity(
provider: IdentityProvider,
providerSubject: string,
): ProvisionedAccount | undefined;
save(account: ProvisionedAccount): void;
}

export class InMemoryIdentityRepository implements IdentityRepository {
private readonly accounts = new Map<string, ProvisionedAccount>();

findByExternalIdentity(
provider: IdentityProvider,
providerSubject: string,
): ProvisionedAccount | undefined {
return this.accounts.get(`${provider}:${providerSubject}`);
}

save(account: ProvisionedAccount): void {
const { provider, providerSubject } = account.externalIdentity;
this.accounts.set(`${provider}:${providerSubject}`, account);
}
}

function requireText(value: string, message: string): string {
const normalized = value.trim();
if (!normalized) {
throw new Error(message);
}
return normalized;
}

function createOpaqueId(): string {
return randomUUID();
}

export class IdentityService {
constructor(private readonly repository: IdentityRepository) {}

signInWithExternalIdentity(input: {
provider: IdentityProvider;
providerSubject: string;
displayName: string;
}): ProvisionedAccount {
const providerSubject = requireText(input.providerSubject, 'Provider subject is required');
const existing = this.repository.findByExternalIdentity(input.provider, providerSubject);
if (existing) {
return existing;
}

const createdAt = new Date().toISOString();
const user: User = {
id: createOpaqueId(),
displayName: requireText(input.displayName, 'Display name is required'),
createdAt,
};
const account: ProvisionedAccount = {
user,
externalIdentity: {
id: createOpaqueId(),
userId: user.id,
provider: input.provider,
providerSubject,
createdAt,
},
workspace: {
id: createOpaqueId(),
ownerUserId: user.id,
name: `${user.displayName}'s workspace`,
kind: 'personal',
createdAt,
},
};

this.repository.save(account);
return account;
}
}
14 changes: 14 additions & 0 deletions docs/superpowers/plans/2026-08-03-identity-workspace-slice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Identity and Personal Workspace Slice

**Goal:** Establish the provider-neutral identity domain used by future Google and GitHub OAuth callbacks.

## Tasks

- [x] Define internal User, ExternalIdentity, and Workspace entities.
- [x] Use opaque UUIDv4 strings for all internal identifiers.
- [x] Keep provider subjects as external attributes rather than internal primary keys.
- [x] Provision exactly one personal workspace on first sign-in.
- [x] Make repeated sign-in idempotent for the same provider identity.
- [x] Add PostgreSQL constraints for provider identity uniqueness and one personal workspace per owner.
- [ ] Add OAuth state, PKCE, callback verification, and secure session issuance in the next slice.
- [ ] Run CI, SAST, Security Scan, and review feedback; fix all actionable findings.
Loading