From 665d4eb02e14de1870870fbf731802d890bb85db Mon Sep 17 00:00:00 2001 From: Lucas Fischer Date: Mon, 24 Aug 2026 21:48:48 +0800 Subject: [PATCH 1/2] feat(licensing): expose the license issue date on License --- Sources/AmoreLicensing/Models/License.swift | 8 +++- .../Payload/LicensePayload.swift | 2 + .../LicensePayloadDecodeTests.swift | 37 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Sources/AmoreLicensing/Models/License.swift b/Sources/AmoreLicensing/Models/License.swift index a3a454e..07c5a51 100644 --- a/Sources/AmoreLicensing/Models/License.swift +++ b/Sources/AmoreLicensing/Models/License.swift @@ -16,6 +16,11 @@ public struct License: Identifiable, Hashable, Codable, Sendable { /// The customer this license was issued to, or `nil` if the license has no /// associated customer. Use `customer?.email` to show "Licensed to ". public var customer: Customer? + /// When the license was issued, or `nil` if the token predates this claim. + /// For purchased licenses this is the purchase time. Useful for showing + /// license details in settings, e.g. to help customers find their email + /// receipt. + public var issuedAt: Date? /// The product name this license is for. @available(*, deprecated, renamed: "product.name", message: "Use `product.name` instead.") @@ -31,7 +36,8 @@ extension License { expiresAt: payload.exp, entitlements: payload.entitlements, subscriptionState: payload.subscriptionState, - customer: payload.customer + customer: payload.customer, + issuedAt: payload.issuedAt ) } diff --git a/Sources/AmoreLicensing/Payload/LicensePayload.swift b/Sources/AmoreLicensing/Payload/LicensePayload.swift index 084cb17..c7656b1 100644 --- a/Sources/AmoreLicensing/Payload/LicensePayload.swift +++ b/Sources/AmoreLicensing/Payload/LicensePayload.swift @@ -10,6 +10,7 @@ struct LicensePayload: Codable, Sendable { var entitlements: Set = [] var subscriptionState: SubscriptionState? var customer: Customer? + var issuedAt: Date? enum CodingKeys: String, CodingKey { case exp @@ -21,5 +22,6 @@ struct LicensePayload: Codable, Sendable { case entitlements case subscriptionState = "subscription_state" case customer + case issuedAt = "issued_at" } } diff --git a/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift b/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift index 49e9877..00645b9 100644 --- a/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift +++ b/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift @@ -126,6 +126,43 @@ struct LicensePayloadDecodeTests { #expect(license.customer == nil) } + @Test func licenseFromPayloadCarriesIssueDate() throws { + let json = """ + { + "exp": 1800000000, + "iat": 1779000000, + "hardware_id": "hw-1", + "license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E", + "nonce": "n-1", + "product": { "name": "Pro", "identifier": "pro" }, + "entitlements": [], + "issued_at": 1778000000 + } + """ + let payload = try decodePayload(json) + let license = License(from: payload) + #expect(license.issuedAt == Date(timeIntervalSince1970: 1_778_000_000)) + } + + // An older token has no `issued_at` claim; the optional must decode to + // nil, not error. + @Test func licenseHasNilIssueDateWhenClaimAbsent() throws { + let json = """ + { + "exp": 1800000000, + "iat": 1779000000, + "hardware_id": "hw-1", + "license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E", + "nonce": "n-1", + "product": { "name": "Pro", "identifier": "pro" }, + "entitlements": [] + } + """ + let payload = try decodePayload(json) + let license = License(from: payload) + #expect(license.issuedAt == nil) + } + // A `customer` object may be present without an `email` (partial or // forward-compatible token). It must decode with a nil email rather than // throw and reject the entire license. From f612ab59851e8ec81882d4068701d550643ad862 Mon Sep 17 00:00:00 2001 From: Lucas Fischer Date: Mon, 24 Aug 2026 22:08:30 +0800 Subject: [PATCH 2/2] feat(licensing): expose the customer name on Customer --- Sources/AmoreLicensing/Models/Customer.swift | 3 ++ Sources/AmoreLicensing/Models/License.swift | 3 +- .../LicensePayloadDecodeTests.swift | 47 ++++++++++++++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/Sources/AmoreLicensing/Models/Customer.swift b/Sources/AmoreLicensing/Models/Customer.swift index f5190a1..3796436 100644 --- a/Sources/AmoreLicensing/Models/Customer.swift +++ b/Sources/AmoreLicensing/Models/Customer.swift @@ -5,4 +5,7 @@ public struct Customer: Hashable, Codable, Sendable { /// The email address the license was issued to, or `nil` if the issuing /// token carries a customer without an email. public var email: String? + /// The customer's name, or `nil` if the issuing token carries a customer + /// without a name. + public var name: String? } diff --git a/Sources/AmoreLicensing/Models/License.swift b/Sources/AmoreLicensing/Models/License.swift index 07c5a51..bad0b62 100644 --- a/Sources/AmoreLicensing/Models/License.swift +++ b/Sources/AmoreLicensing/Models/License.swift @@ -19,7 +19,8 @@ public struct License: Identifiable, Hashable, Codable, Sendable { /// When the license was issued, or `nil` if the token predates this claim. /// For purchased licenses this is the purchase time. Useful for showing /// license details in settings, e.g. to help customers find their email - /// receipt. + /// receipt. The server carries this claim on every token it mints for the + /// license, so the value is stable across validations. public var issuedAt: Date? /// The product name this license is for. diff --git a/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift b/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift index 00645b9..44b37dc 100644 --- a/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift +++ b/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift @@ -99,12 +99,34 @@ struct LicensePayloadDecodeTests { "nonce": "n-1", "product": { "name": "Pro", "identifier": "pro" }, "entitlements": [], - "customer": { "email": "buyer@example.com" } + "customer": { "email": "buyer@example.com", "name": "Amy Worrall" } } """ let payload = try decodePayload(json) let license = License(from: payload) #expect(license.customer?.email == "buyer@example.com") + #expect(license.customer?.name == "Amy Worrall") + } + + // A name-only customer (license issued without an email) must decode with + // a nil email and carry the name. + @Test func customerDecodesWithNameOnly() throws { + let json = """ + { + "exp": 1800000000, + "iat": 1779000000, + "hardware_id": "hw-1", + "license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E", + "nonce": "n-1", + "product": { "name": "Pro", "identifier": "pro" }, + "entitlements": [], + "customer": { "name": "Amy Worrall" } + } + """ + let payload = try decodePayload(json) + let license = License(from: payload) + #expect(license.customer?.name == "Amy Worrall") + #expect(license.customer?.email == nil) } // An older token (or any license issued without an email) has no `customer` @@ -126,6 +148,29 @@ struct LicensePayloadDecodeTests { #expect(license.customer == nil) } + // The full claim set a current server mints: customer name and email plus + // the issue date, all on one token. + @Test func decodesFullCurrentTokenShape() throws { + let json = """ + { + "exp": 1800000000, + "iat": 1779000000, + "hardware_id": "hw-1", + "license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E", + "nonce": "n-1", + "product": { "name": "Pro", "identifier": "pro" }, + "entitlements": [], + "customer": { "email": "buyer@example.com", "name": "Amy Worrall" }, + "issued_at": 1778000000 + } + """ + let payload = try decodePayload(json) + let license = License(from: payload) + #expect(license.customer?.email == "buyer@example.com") + #expect(license.customer?.name == "Amy Worrall") + #expect(license.issuedAt == Date(timeIntervalSince1970: 1_778_000_000)) + } + @Test func licenseFromPayloadCarriesIssueDate() throws { let json = """ {