@porulle/plugin-pos 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  import { eq, and } from "@porulle/core/drizzle";
2
2
  import { Ok, Err } from "@porulle/core";
3
3
  import type { PluginResult } from "@porulle/core";
4
- import { posOperatorPins, posShifts } from "../schema.js";
5
- import type { Db, OperatorPin, Shift } from "../types.js";
4
+ import { user } from "@porulle/core/auth-schema";
5
+ import { posOperatorPins, posPinAttempts, posShifts } from "../schema.js";
6
+ import type { Db, OperatorPin, PinAttempt, Shift } from "../types.js";
6
7
 
7
8
  /**
8
9
  * PIN auth runtime (issue #51).
@@ -16,6 +17,14 @@ import type { Db, OperatorPin, Shift } from "../types.js";
16
17
  */
17
18
 
18
19
  const PBKDF2_ITERATIONS = 100_000;
20
+ const DEFAULT_LOCKOUT_MAX_ATTEMPTS = 5;
21
+ const DEFAULT_LOCKOUT_WINDOW_MINUTES = 15;
22
+ export const PIN_LOCKOUT_ERROR = "Too many failed PIN attempts; try again later";
23
+
24
+ type PinVerifyOutcome =
25
+ | { status: "ok"; record: OperatorPin }
26
+ | { status: "locked" }
27
+ | { status: "invalid" };
19
28
 
20
29
  /** Narrow Better Auth surface the PIN runtime uses. */
21
30
  export interface PinAuthApi {
@@ -70,6 +79,23 @@ export async function verifyPinHash(pin: string, encoded: string): Promise<boole
70
79
  return diff === 0;
71
80
  }
72
81
 
82
+ async function ensureOperatorUser(db: Db, operatorId: string): Promise<void> {
83
+ // SEC-16/R-01: ensure ONLY a user row exists so a user-referenced API key can
84
+ // be minted. We create NO org membership and grant NO role — better-auth's
85
+ // org-referenced keys require owner/admin (a cashier->owner escalation), so
86
+ // the key is user-referenced and carries the operator's store in metadata,
87
+ // read back by authMiddleware.
88
+ const users = await db.select().from(user).where(eq(user.id, operatorId));
89
+ if (users.length === 0) {
90
+ await db.insert(user).values({
91
+ id: operatorId,
92
+ name: operatorId,
93
+ email: `${operatorId}@pos.local`,
94
+ emailVerified: true,
95
+ });
96
+ }
97
+ }
98
+
73
99
  export class PinService {
74
100
  constructor(
75
101
  private db: Db,
@@ -78,9 +104,80 @@ export class PinService {
78
104
  apiKeyScope?: string | undefined;
79
105
  /** Shift-credential lifetime in seconds. Default: 12h. */
80
106
  credentialTtlSeconds?: number | undefined;
107
+ /** Failed PIN attempts before lockout (SEC-15). Default: 5. */
108
+ lockoutMaxAttempts?: number | undefined;
109
+ /** Failure window and lockout duration in minutes (SEC-15). Default: 15. */
110
+ lockoutWindowMinutes?: number | undefined;
81
111
  } = {},
82
112
  ) {}
83
113
 
114
+ private lockoutMaxAttempts(): number {
115
+ return this.options.lockoutMaxAttempts ?? DEFAULT_LOCKOUT_MAX_ATTEMPTS;
116
+ }
117
+
118
+ private lockoutWindowMs(): number {
119
+ const minutes = this.options.lockoutWindowMinutes ?? DEFAULT_LOCKOUT_WINDOW_MINUTES;
120
+ return minutes * 60 * 1000;
121
+ }
122
+
123
+ private async getPinAttempt(
124
+ orgId: string,
125
+ operatorId: string,
126
+ ): Promise<PinAttempt | undefined> {
127
+ const rows = await this.db
128
+ .select()
129
+ .from(posPinAttempts)
130
+ .where(and(
131
+ eq(posPinAttempts.organizationId, orgId),
132
+ eq(posPinAttempts.operatorId, operatorId),
133
+ ));
134
+ return rows[0] as PinAttempt | undefined;
135
+ }
136
+
137
+ private isLockedOut(attempt: PinAttempt | undefined, now: Date): boolean {
138
+ return attempt?.lockedUntil != null && attempt.lockedUntil > now;
139
+ }
140
+
141
+ private async recordFailedPinAttempt(orgId: string, operatorId: string): Promise<void> {
142
+ const now = new Date();
143
+ const windowMs = this.lockoutWindowMs();
144
+ const maxAttempts = this.lockoutMaxAttempts();
145
+ const existing = await this.getPinAttempt(orgId, operatorId);
146
+
147
+ let failedCount = 1;
148
+ if (existing) {
149
+ const windowExpired = now.getTime() - existing.updatedAt.getTime() > windowMs;
150
+ failedCount = windowExpired ? 1 : existing.failedCount + 1;
151
+ }
152
+
153
+ const lockedUntil = failedCount >= maxAttempts
154
+ ? new Date(now.getTime() + windowMs)
155
+ : null;
156
+
157
+ if (existing) {
158
+ await this.db
159
+ .update(posPinAttempts)
160
+ .set({ failedCount, lockedUntil, updatedAt: now })
161
+ .where(eq(posPinAttempts.id, existing.id));
162
+ } else {
163
+ await this.db.insert(posPinAttempts).values({
164
+ organizationId: orgId,
165
+ operatorId,
166
+ failedCount,
167
+ lockedUntil,
168
+ });
169
+ }
170
+ }
171
+
172
+ private async clearPinAttempts(orgId: string, operatorId: string): Promise<void> {
173
+ const existing = await this.getPinAttempt(orgId, operatorId);
174
+ if (!existing) return;
175
+ await this.db
176
+ .update(posPinAttempts)
177
+ .set({ failedCount: 0, lockedUntil: null, updatedAt: new Date() })
178
+ .where(eq(posPinAttempts.id, existing.id));
179
+ }
180
+
84
181
  async setPin(
85
182
  orgId: string,
86
183
  input: { operatorId: string; pin: string; canOverride?: boolean | undefined },
@@ -120,7 +217,11 @@ export class PinService {
120
217
  orgId: string,
121
218
  operatorId: string,
122
219
  pin: string,
123
- ): Promise<OperatorPin | null> {
220
+ ): Promise<PinVerifyOutcome> {
221
+ const now = new Date();
222
+ const attempt = await this.getPinAttempt(orgId, operatorId);
223
+ if (this.isLockedOut(attempt, now)) return { status: "locked" };
224
+
124
225
  const rows = await this.db
125
226
  .select()
126
227
  .from(posOperatorPins)
@@ -129,8 +230,18 @@ export class PinService {
129
230
  eq(posOperatorPins.operatorId, operatorId),
130
231
  ));
131
232
  const record = rows[0] as OperatorPin | undefined;
132
- if (!record) return null;
133
- return (await verifyPinHash(pin, record.pinHash)) ? record : null;
233
+ if (!record) {
234
+ await this.recordFailedPinAttempt(orgId, operatorId);
235
+ return { status: "invalid" };
236
+ }
237
+
238
+ if (!(await verifyPinHash(pin, record.pinHash))) {
239
+ await this.recordFailedPinAttempt(orgId, operatorId);
240
+ return { status: "invalid" };
241
+ }
242
+
243
+ await this.clearPinAttempts(orgId, operatorId);
244
+ return { status: "ok", record };
134
245
  }
135
246
 
136
247
  /**
@@ -149,8 +260,9 @@ export class PinService {
149
260
  apiKey: string;
150
261
  expiresAt: string;
151
262
  }>> {
152
- const record = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
153
- if (!record) return Err("Invalid operator or PIN");
263
+ const verified = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
264
+ if (verified.status === "locked") return Err(PIN_LOCKOUT_ERROR);
265
+ if (verified.status !== "ok") return Err("Invalid operator or PIN");
154
266
 
155
267
  const conditions = [
156
268
  eq(posShifts.organizationId, orgId),
@@ -166,22 +278,31 @@ export class PinService {
166
278
  return Err("PIN login requires the Better Auth instance (plugin ctx.auth) — are routes mounted by createServer?");
167
279
  }
168
280
 
281
+ await ensureOperatorUser(this.db, input.operatorId);
282
+
169
283
  const ttlSeconds = this.options.credentialTtlSeconds ?? 12 * 3600;
170
- const created = (await auth.api.createApiKey({
171
- body: {
172
- // Better Auth caps name length at 32; the full shift id lives on the
173
- // shift row (metadata.pinLoginApiKeyId links back).
174
- name: `pos-shift-${shift.id.slice(0, 8)}`,
175
- userId: input.operatorId,
176
- expiresIn: ttlSeconds,
177
- permissions: { pos: ["operate"] },
178
- // Shift/terminal binding is recorded on the shift row (metadata is
179
- // disabled by default in Better Auth's apiKey plugin); the key name
180
- // carries the shift id for traceability.
181
- // The plugin registers this scope via its apiKeyScopes manifest.
182
- configId: this.options.apiKeyScope ?? "pos",
183
- },
184
- })) as { key?: string; id?: string };
284
+ let created: { key?: string; id?: string };
285
+ try {
286
+ created = (await auth.api.createApiKey({
287
+ body: {
288
+ // Better Auth caps name length at 32; the full shift id lives on the
289
+ // shift row (metadata.pinLoginApiKeyId links back).
290
+ name: `pos-shift-${shift.id.slice(0, 8)}`,
291
+ userId: input.operatorId,
292
+ organizationId: orgId,
293
+ expiresIn: ttlSeconds,
294
+ permissions: { pos: ["operate"] },
295
+ metadata: { operatorId: input.operatorId, organizationId: orgId },
296
+ // Shift/terminal binding is recorded on the shift row; the key name
297
+ // carries the shift id for traceability.
298
+ // The plugin registers this scope via its apiKeyScopes manifest.
299
+ configId: this.options.apiKeyScope ?? "pos",
300
+ },
301
+ })) as { key?: string; id?: string };
302
+ } catch (error) {
303
+ const message = error instanceof Error ? error.message : "Failed to mint shift credential";
304
+ return Err(message);
305
+ }
185
306
  if (!created?.key) return Err("Failed to mint shift credential");
186
307
 
187
308
  // Record the credential on the shift so admins can trace/revoke it.
@@ -215,9 +336,10 @@ export class PinService {
215
336
  action: string;
216
337
  approvedAt: string;
217
338
  }>> {
218
- const record = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
219
- if (!record) return Err("Invalid operator or PIN");
220
- if (!record.canOverride) return Err("This operator cannot approve overrides");
339
+ const verified = await this.verifyOperatorPin(orgId, input.operatorId, input.pin);
340
+ if (verified.status === "locked") return Err(PIN_LOCKOUT_ERROR);
341
+ if (verified.status !== "ok") return Err("Invalid operator or PIN");
342
+ if (!verified.record.canOverride) return Err("This operator cannot approve overrides");
221
343
  return Ok({
222
344
  approved: true,
223
345
  operatorId: input.operatorId,
@@ -25,7 +25,7 @@ export class TransactionService {
25
25
  .where(and(eq(posShifts.id, input.shiftId), eq(posShifts.status, "open")));
26
26
  if (shifts.length === 0) return Err("Shift is not open");
27
27
 
28
- const receiptNumber = await this.generateReceiptNumber(input.terminalId);
28
+ const receiptNumber = await this.generateReceiptNumber(orgId, input.terminalId);
29
29
 
30
30
  const rows = await this.db
31
31
  .insert(posTransactions)
@@ -121,7 +121,7 @@ export class TransactionService {
121
121
  await tx
122
122
  .update(posShifts)
123
123
  .set({ voidsCount: sql`${posShifts.voidsCount} + 1`, updatedAt: new Date() })
124
- .where(eq(posShifts.id, txn.shiftId));
124
+ .where(and(eq(posShifts.id, txn.shiftId), eq(posShifts.organizationId, orgId)));
125
125
 
126
126
  return Ok(updated[0]!);
127
127
  });
@@ -207,7 +207,7 @@ export class TransactionService {
207
207
  salesTotal: sql`${posShifts.salesTotal} + ${paymentTotal}`,
208
208
  updatedAt: new Date(),
209
209
  })
210
- .where(eq(posShifts.id, txn.shiftId));
210
+ .where(and(eq(posShifts.id, txn.shiftId), eq(posShifts.organizationId, txn.organizationId)));
211
211
  }
212
212
 
213
213
  return Ok(txn);
@@ -219,7 +219,7 @@ export class TransactionService {
219
219
  // ─── Receipt Number Generation ─────────────────────────────────────
220
220
  // Sequential per terminal per day: {terminal_code}-{sequence}
221
221
 
222
- private async generateReceiptNumber(terminalId: string): Promise<string> {
222
+ private async generateReceiptNumber(orgId: string, terminalId: string): Promise<string> {
223
223
  const today = new Date();
224
224
  const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
225
225
  const startOfDayISO = startOfDay.toISOString();
@@ -238,6 +238,7 @@ export class TransactionService {
238
238
  .select({ count: sql<number>`COUNT(*)`.as("count") })
239
239
  .from(posTransactions)
240
240
  .where(and(
241
+ eq(posTransactions.organizationId, orgId),
241
242
  eq(posTransactions.terminalId, terminalId),
242
243
  sql`${posTransactions.createdAt} >= ${startOfDayISO}::timestamptz`,
243
244
  ));
package/src/types.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  posPayments,
8
8
  posReturnItems,
9
9
  posOperatorPins,
10
+ posPinAttempts,
10
11
  } from "./schema.js";
11
12
 
12
13
  export type Terminal = typeof posTerminals.$inferSelect;
@@ -30,6 +31,9 @@ export type ReturnItemInsert = typeof posReturnItems.$inferInsert;
30
31
  export type OperatorPin = typeof posOperatorPins.$inferSelect;
31
32
  export type OperatorPinInsert = typeof posOperatorPins.$inferInsert;
32
33
 
34
+ export type PinAttempt = typeof posPinAttempts.$inferSelect;
35
+ export type PinAttemptInsert = typeof posPinAttempts.$inferInsert;
36
+
33
37
  export type TransactionStatus = "open" | "held" | "completed" | "voided";
34
38
  export type TransactionType = "sale" | "return" | "exchange";
35
39
  export type PaymentMethod = "cash" | "card" | "gift_card" | "store_credit" | "other";
@@ -50,6 +54,10 @@ export interface POSPluginOptions {
50
54
  apiKeyScope?: string;
51
55
  /** Shift-credential lifetime in seconds. Default: 43200 (12h). */
52
56
  credentialTtlSeconds?: number;
57
+ /** Failed PIN attempts before lockout. Default: 5 (SEC-15). */
58
+ lockoutMaxAttempts?: number;
59
+ /** Failure counting window and lockout duration in minutes. Default: 15 (SEC-15). */
60
+ lockoutWindowMinutes?: number;
53
61
  };
54
62
  }
55
63