Phase 1-3: Domain Layer Foundation - Add value objects: IdentityId, PermissionId, RelayId, WalletId, Nickname, NostrKeyPair - Add rich domain entities: Identity, Permission, Relay with behavior - Add domain events: IdentityCreated, IdentityRenamed, IdentitySelected, etc. - Add repository interfaces for Identity, Permission, Relay - Add infrastructure layer with repository implementations - Add EncryptionService abstraction Phase 4: Ubiquitous Language Cleanup - Rename BrowserSyncData → EncryptedVault (encrypted vault storage) - Rename BrowserSessionData → VaultSession (decrypted session state) - Rename SignerMetaData → ExtensionSettings (extension configuration) - Rename Identity_ENCRYPTED → StoredIdentity (storage DTO) - Rename Identity_DECRYPTED → IdentityData (session DTO) - Similar renames for Permission, Relay, NwcConnection, CashuMint - Add backwards compatibility aliases with @deprecated markers Test Coverage - Add comprehensive tests for all value objects - Add tests for domain entities and their behavior - Add tests for domain events - Fix PermissionChecker to prioritize kind-specific rules over blanket rules - Fix pre-existing component test issues (IconButton, Pubkey) All 113 tests pass. Both Chrome and Firefox builds succeed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
333 lines
9.6 KiB
TypeScript
333 lines
9.6 KiB
TypeScript
import { IdentityId, PermissionId } from '../value-objects';
|
|
import type {
|
|
PermissionSnapshot,
|
|
ExtensionMethod,
|
|
PermissionPolicy,
|
|
} from '../repositories/permission-repository';
|
|
|
|
/**
|
|
* Permission entity - represents an authorization decision for
|
|
* a specific identity, host, and method combination.
|
|
*
|
|
* Permissions are immutable once created - to change a permission,
|
|
* delete it and create a new one.
|
|
*/
|
|
export class Permission {
|
|
private readonly _id: PermissionId;
|
|
private readonly _identityId: IdentityId;
|
|
private readonly _host: string;
|
|
private readonly _method: ExtensionMethod;
|
|
private readonly _policy: PermissionPolicy;
|
|
private readonly _kind?: number;
|
|
|
|
private constructor(
|
|
id: PermissionId,
|
|
identityId: IdentityId,
|
|
host: string,
|
|
method: ExtensionMethod,
|
|
policy: PermissionPolicy,
|
|
kind?: number
|
|
) {
|
|
this._id = id;
|
|
this._identityId = identityId;
|
|
this._host = host;
|
|
this._method = method;
|
|
this._policy = policy;
|
|
this._kind = kind;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Factory Methods
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Create an "allow" permission.
|
|
*/
|
|
static allow(
|
|
identityId: IdentityId,
|
|
host: string,
|
|
method: ExtensionMethod,
|
|
kind?: number
|
|
): Permission {
|
|
return new Permission(
|
|
PermissionId.generate(),
|
|
identityId,
|
|
Permission.normalizeHost(host),
|
|
method,
|
|
'allow',
|
|
kind
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a "deny" permission.
|
|
*/
|
|
static deny(
|
|
identityId: IdentityId,
|
|
host: string,
|
|
method: ExtensionMethod,
|
|
kind?: number
|
|
): Permission {
|
|
return new Permission(
|
|
PermissionId.generate(),
|
|
identityId,
|
|
Permission.normalizeHost(host),
|
|
method,
|
|
'deny',
|
|
kind
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a permission with explicit policy.
|
|
*/
|
|
static create(
|
|
identityId: IdentityId,
|
|
host: string,
|
|
method: ExtensionMethod,
|
|
policy: PermissionPolicy,
|
|
kind?: number
|
|
): Permission {
|
|
return new Permission(
|
|
PermissionId.generate(),
|
|
identityId,
|
|
Permission.normalizeHost(host),
|
|
method,
|
|
policy,
|
|
kind
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reconstitute a permission from storage.
|
|
*/
|
|
static fromSnapshot(snapshot: PermissionSnapshot): Permission {
|
|
return new Permission(
|
|
PermissionId.from(snapshot.id),
|
|
IdentityId.from(snapshot.identityId),
|
|
snapshot.host,
|
|
snapshot.method,
|
|
snapshot.methodPolicy,
|
|
snapshot.kind
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Getters
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
get id(): PermissionId {
|
|
return this._id;
|
|
}
|
|
|
|
get identityId(): IdentityId {
|
|
return this._identityId;
|
|
}
|
|
|
|
get host(): string {
|
|
return this._host;
|
|
}
|
|
|
|
get method(): ExtensionMethod {
|
|
return this._method;
|
|
}
|
|
|
|
get policy(): PermissionPolicy {
|
|
return this._policy;
|
|
}
|
|
|
|
get kind(): number | undefined {
|
|
return this._kind;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Behavior
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Check if this permission allows the action.
|
|
*/
|
|
isAllowed(): boolean {
|
|
return this._policy === 'allow';
|
|
}
|
|
|
|
/**
|
|
* Check if this permission denies the action.
|
|
*/
|
|
isDenied(): boolean {
|
|
return this._policy === 'deny';
|
|
}
|
|
|
|
/**
|
|
* Check if this permission matches the given criteria.
|
|
* For signEvent with kind specified, also checks the kind.
|
|
*/
|
|
matches(
|
|
identityId: IdentityId,
|
|
host: string,
|
|
method: ExtensionMethod,
|
|
kind?: number
|
|
): boolean {
|
|
if (!this._identityId.equals(identityId)) {
|
|
return false;
|
|
}
|
|
|
|
if (this._host !== Permission.normalizeHost(host)) {
|
|
return false;
|
|
}
|
|
|
|
if (this._method !== method) {
|
|
return false;
|
|
}
|
|
|
|
// For signEvent, handle kind matching
|
|
if (method === 'signEvent') {
|
|
// If this permission has no kind, it matches all kinds
|
|
if (this._kind === undefined) {
|
|
return true;
|
|
}
|
|
// If checking a specific kind, must match exactly
|
|
return this._kind === kind;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check if this permission applies to a specific event kind.
|
|
* Only relevant for signEvent method.
|
|
*/
|
|
appliesToKind(kind: number): boolean {
|
|
if (this._method !== 'signEvent') {
|
|
return false;
|
|
}
|
|
// No kind restriction means applies to all
|
|
if (this._kind === undefined) {
|
|
return true;
|
|
}
|
|
return this._kind === kind;
|
|
}
|
|
|
|
/**
|
|
* Check if this is a blanket permission (no kind restriction).
|
|
*/
|
|
isBlanketPermission(): boolean {
|
|
return this._method === 'signEvent' && this._kind === undefined;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Persistence
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Convert to a snapshot for persistence.
|
|
*/
|
|
toSnapshot(): PermissionSnapshot {
|
|
const snapshot: PermissionSnapshot = {
|
|
id: this._id.value,
|
|
identityId: this._identityId.value,
|
|
host: this._host,
|
|
method: this._method,
|
|
methodPolicy: this._policy,
|
|
};
|
|
|
|
if (this._kind !== undefined) {
|
|
snapshot.kind = this._kind;
|
|
}
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Equality
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Check equality based on permission ID.
|
|
*/
|
|
equals(other: Permission): boolean {
|
|
return this._id.equals(other._id);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
private static normalizeHost(host: string): string {
|
|
return host.toLowerCase().trim();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Permission checker - evaluates permissions for a request.
|
|
* This encapsulates the permission checking logic.
|
|
*/
|
|
export class PermissionChecker {
|
|
constructor(private readonly permissions: Permission[]) {}
|
|
|
|
/**
|
|
* Check if an action is allowed.
|
|
*
|
|
* @returns true if allowed, false if denied, undefined if no matching permission
|
|
*/
|
|
check(
|
|
identityId: IdentityId,
|
|
host: string,
|
|
method: ExtensionMethod,
|
|
kind?: number
|
|
): boolean | undefined {
|
|
const matching = this.permissions.filter((p) =>
|
|
p.matches(identityId, host, method, kind)
|
|
);
|
|
|
|
if (matching.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
// For signEvent with kind, check specific rules
|
|
// Kind-specific rules take priority over blanket rules
|
|
if (method === 'signEvent' && kind !== undefined) {
|
|
// Check for specific kind deny first (takes priority)
|
|
if (matching.some((p) => p.kind === kind && p.isDenied())) {
|
|
return false;
|
|
}
|
|
|
|
// Check for specific kind allow
|
|
if (matching.some((p) => p.kind === kind && p.isAllowed())) {
|
|
return true;
|
|
}
|
|
|
|
// Fall back to blanket allow (no kind restriction)
|
|
if (matching.some((p) => p.isBlanketPermission() && p.isAllowed())) {
|
|
return true;
|
|
}
|
|
|
|
// Fall back to blanket deny
|
|
if (matching.some((p) => p.isBlanketPermission() && p.isDenied())) {
|
|
return false;
|
|
}
|
|
|
|
// No specific rule found
|
|
return undefined;
|
|
}
|
|
|
|
// For other methods, all matching permissions must allow
|
|
return matching.every((p) => p.isAllowed());
|
|
}
|
|
|
|
/**
|
|
* Get all permissions for a specific identity.
|
|
*/
|
|
forIdentity(identityId: IdentityId): Permission[] {
|
|
return this.permissions.filter((p) => p.identityId.equals(identityId));
|
|
}
|
|
|
|
/**
|
|
* Get all permissions for a specific host.
|
|
*/
|
|
forHost(host: string): Permission[] {
|
|
const normalizedHost = host.toLowerCase().trim();
|
|
return this.permissions.filter((p) => p.host === normalizedHost);
|
|
}
|
|
}
|